Vercelにおける無限スケーリングリダイレクト
Vercelは、従来のルーティングルールやミドルウェアに代わり、ブルームフィルターとシャーディングを組み合わせた新しいアーキテクチャにより、数百万件のリダイレクトを低レイテンシで処理するシステムを実装した。
キーポイント
既存手法の限界
従来のルーティングルール(最大2,000件)やミドルウェアは、リダイレクト数が膨大になるとレイテンシとコストが線形に増加するシステム上の問題を引き起こす。
ブルームフィルターの活用
リクエストがリダイレクト対象でない場合を高速に除外するため、確率的データ構造であるブルームフィルターを採用し、「リダイレクトなし」パスの負荷を最小化した。
シャーディングによるメモリ最適化
巨大なJSONLファイルの読み込みを避けるため、パスをハッシュして複数の小さなシャードに分割し、外部ストレージとファイルシステムキャッシュを活用することでメモリ使用量を抑制した。
設計思想と実装
単純さとデバッグ可能性を重視し、JSONL形式とブルームフィルターを単一ファイルで管理するシンプルな設計から始め、反復的に改善を行った。
Shardingによるリダイレクトの分散と負荷軽減
巨大なJSONLファイルの代わりにパスをハッシュして複数の小さなシャードに分散し、メモリ負荷を外部ストレージとファイルシステムキャッシュへ移行させた。
Bloomフィルタによる高速なネガティブルックアップ
リクエスト時にまずBloomフィルタをチェックし、存在しないキーの場合はJSON本体を解析せずに即座に処理を終了させることで高速化を実現した。
JSON解析のボトルネックとシャードサイズとのトレードオフ
ポジティブルックアップ時のJSON解析がCPUボトルネックとなり、シャードサイズを小さくするとI/Oレイテンシが増加するトレードオフが生じた。
影響分析・編集コメントを表示
影響分析
この技術革新は、大規模なWebアプリケーションやエッジコンピューティング環境におけるルーティング最適化のベストプラクティスを示唆している。特に、トラフィックが膨大でリダイレクトロジックが複雑になるケースにおいて、システムのパフォーマンスとコスト効率を両立させるための具体的なアーキテクチャ指針として、開発者に重要な示唆を与える。
編集コメント
Vercelのこの取り組みは、単なる機能追加ではなく、インフラストラクチャレベルでのスケーラビリティ課題解決を示す良い例です。大規模なWeb開発において、ルーティングのオーバーヘッドをどう設計するかは重要な検討事項となります。
リダイレクトは小規模では単純ですが、数百万件になるとレイテンシとコストが実際のシステム上の問題となります。
以前、Vercel ではリダイレクトはルーティングルールとミドルウェアによって処理されていました。ルーティングルールはワイルドカードを含む最大 2,000 の複雑なリダイレクトをサポートしており、順序付きリストとして順次評価されます。各ルールには正規表現マッチングが含まれる可能性があり、1 つのリクエストで多数の高コストな評価がトリガーされる可能性があります。これは数千件のルーティングルールであれば許容範囲ですが、件数が増えるとリクエストあたりの処理量が線形に増加します。
ミドルウェアはより柔軟性がありますが、すべてのリクエストで追加コードを実行するためレイテンシが発生します。低レイテンシで数百万件のリダイレクトを提供するには、リクエストあたりほぼ定数時間または対数時間で動作する専用のルックアップパスが必要でした。ブルームフィルタ(Bloom filters)を用いてグローバルルーティングを高速化した以前の取り組みに基づき、私たちは数百万件へのスケーリングを実現する方法を見つけました。
最適化の目標
スケール:
- 各プロジェクトで数百万件の静的リダイレクトをサポート
ランタイム動作:
- リダイレクトを設定していないプロジェクトには追加のレイテンシコストが発生しない
- ほとんどのリクエストはリダイレクトされないため、高速な「リダイレクトなし」パスを確保
低プロセスメモリ使用量:外部ストレージとキャッシュ層に依存し、メモリ使用を抑える
エンジニアリングの価値観:
- 先走った最適化よりも、シンプルさとデバッグ容易性を優先
- 最初から完璧を目指そうとするのではなく、反復的に進化させる
これらの目標を念頭に置き、私たちは考え得る最もシンプルな設計から始めました。リダイレクトと Bloom フィルターを単一のファイルに統合したものです。リダイレクトデータはすでに JSON 形式であり、Bloom フィルターも JSON エクスポートをサポートしていたため、この情報を保存するために JSONL ファイル形式を採用することにしました。
JSON と Bloom フィルター versus ナプキン計算
Bloom フィルター(Bloom filter)とは、ある要素が集合に属しているかどうかを検証する確率的データ構造です。Bloom フィルターは偽陽性(false positive)を返す可能性がありますが、偽陰性(false negative)を返すことは決してありません。つまり、「確実に集合に含まれない」か「集合に含まれる可能性がある」という回答を行います。まず小さくキャッシュされた Bloom フィルターをチェックすることで、一致しないリクエストについてはリダイクトルックアップ自体をスキップでき、一般的な「リダイレクトなし」のパスを極めて低コストに保つことができます。一致が検出された場合にのみ、JSON ファイルの解析が行われます。
シンプルですが、これはスケーラブルでしょうか?ナプキン計算(napkin math)では「いいえ」という答えでした。100 万件のリダイレクトがあれば、ファイルサイズが数百メガバイトに達することは容易にあり得ます。そのような巨大なデータをフェッチして解析すれば、レイテンシとメモリの予算を圧迫してしまうでしょう。一度にデータセット全体を読み込むことは避けなければなりませんでした。
シャード化と Bloom フィルターでメモリ使用量を低く保ち、検索速度を高速化する
解決策はシャーディングでした。巨大な JSONL ファイル 1 つの代わりに、リダイレクトパスをハッシュ化してエントリを多数の小規模なシャードに分散させました。これにより、特定のリクエストに対してデータの一部のみを読み込むことが可能になり、負荷がプロセスメモリから外部ストレージおよびファイルシステムキャッシュへとシフトします。ブルームフィルタは依然として先頭に配置され、大部分のトラフィックに対する照会を短絡化しています。しかし今、リクエストがブルームフィルタを通過した場合でも、リダイレクトセット全体ではなく、単一の小さなシャードのみを取得して解析すればよいようになりました。
シャード構造
各シャードは 3 つの部分で構成されています:
- ブルームフィルタのプロパティを符号化したヘッダー行
- Base64 でエンコードされたブルームフィルタ
- src パスをキーとするリダイレクトの JSON オブジェクト
以下にサンプルを示します:
ビルド時には、すべてのシャードとそのブルームフィルタを生成し、外部ストレージへアップロードします。ランタイムでは、サーバーはリクエストを受信した際に、特定のプロジェクトまたはデプロイメントに対してどのデータセットとシャード数が適用されるかを知るだけで十分です。
照会パスでは JSON を解析する前にブルームフィルタをチェックします
リクエスト時には、一括リダイレクトの照会は以下の手順で行われます:
- プロジェクトまたはデプロイメントに一括リダイレクトが設定されているか確認します。設定されていない場合は、すべての処理をスキップして通常通り進行します。
- 受信したリクエストからリダイレクトキーを計算し、ハッシュ化して対象のシャードを特定します。
- シャードをキャッシュまたはオリジンから取得し、ブルームフィルタをチェックします。
- キーがブルームフィルタに含まれていない場合、シャードの JSON 本文は解析しません。
キーが Bloom フィルタに存在する可能性がある場合、そのシャードの JSON 本体を読み込み、そのオブジェクト内で正確なリダイレクトを検索します。
この設計にはいくつかの良い特性があります:
高速なネガティブ検索: Bloom フィルタは非常に高速であり、偽陽性率を極めて低く設定できるよう調整可能です
人間が読みやすいシャード: シャードは単なる JSONL ファイルです。何か問題が発生した場合でも、シャードをダンプしてその内容を正確に確認するのは容易です
実装リスクの低さ: JSON パースと Bloom フィルタはシンプルであるため、迅速にリリースでき、実際の運用データを収集することが可能です
JSON パースが正の検索におけるボトルネックとなった
JSON パースがボトルネックになる可能性を懸念しており、社内での活用(dogfooding)によってそれが確認されました。Bloom フィルタでリダイレクトが存在する可能性があることが示された場合、関連するシャードの完全な JSON 本体をパースするのにかなりの時間がかかりました。また、JSON パースは CPU を多く消費し、ノード上の他のすべての処理とリソースを競合するため、CPU 負荷が高い状況では著しいレイテンシのスパイクも観測されました。
シャードサイズを小さくすればパース速度が向上しますが、シャードが小さいほど管理すべきシャード数(カルディナリティ)やキャッシュミス率が増加します。これによりトレードオフが生じました。大きなシャードはパースによる CPU オーバーヘッドが大きくなる一方、小さなシャードはキャッシュミスによる I/O レイテンシが大きくなります。シャード全体をパースすることなく単一の値を取得できるデータ形式が必要でした。
シャード全体をパースしないために、ソート済みキーに対してバイナリサーチを行う
リダイレクトを JSON ブロブに保存する代わりに、リダイレクトパスをキーとしたバイナリ探索を実装しました。各シャードはリダイレクトキーをソート済み順序で保持しているため、これらのキーに対して対数時間の検索を行うことができます。キーを見つけたら、その特定のリダイレクトの JSON だけを読み解けばよいのです。これにより、シャードサイズの課題を完全に回避できます。検索コストがシャード内のデータ総量に比例して増加しなくなるため、キャッシュヒット率を高めるためにシャードを十分に大きく保ちつつ、フル JSON パースのコストを支払う必要がなくなります。
レイテンシが低下し、スパイクが消滅しました
正の検索(リダイレクトが存在する場合)におけるホットパスから JSON パースが外れたことで、実際に存在するリダイレクトへのリクエストは、より高速かつ予測可能になりました。
最も目に見える改善点は、高い CPU 負荷下で発生していたレイテンシスパイクの完全な排除です。フル JSON シャードをパースする際、リダイレクト検索はノード上で実行されている他のすべての処理と CPU 時間を競合していました。バイナリ探索を採用したことで、1 リクエストあたりの CPU コストが十分に低下し、リソース競合が要因となることはなくなりました。
一般的なケースを想定した設計
リダイレクト自体は単純です。課題は、この単純な抽象化を、主にコールド(アクセス頻度の低い)な大規模データセットと、エッジでの厳格なレイテンシ要件と組み合わせる点にあります。ルーティングルールはこのタスクには不適切なツールでした。
代わりに、バッチリダイレクト専用のパスを構築しました:
- リダイレクトデータをシャード化し、各ピースが小さく保たれるようにする
- ブルームフィルタを使用し、一般的な「リダイレクトなし」ケースのコストを抑える
キーに対して二分探索をサポートするレイアウトにリダイレクトを保存する
今回の開発サイクルは、私たちが繰り返し強調している原則を再確認させるものでした。先行的な最適化を避けることです。シンプルでデバッグ可能な実装から始め、それを計測することで、実際に複雑さが必要となる箇所を生産データが示してくれるようになります。
一括リダイレクトの開始方法
一括リダイストは Pro および Enterprise カスタマー向けに利用可能で、プロジェクト設定、ダッシュボード、API、または CLI を経由して構成できます。現在の制限はプロジェクトあたり 100 万個のリダイレクトです。より多くの容量が必要な場合は、お問い合わせください。
プラン
含まれるリダイレクト数
追加容量
Pro
プロジェクトあたり 1,000 件
25,000 件ごとに月額 $50
Enterprise
プロジェクトあたり 10,000 件
25,000 件ごとに月額 $50
一括リダイレクトを使用して、大規模な移行の管理、壊れたリンクの修正、期限切れページの処理などを行ってください。詳細は、一括リダイレクトのドキュメントまたはスタートガイドをご覧ください。
続きを読む
原文を表示
Redirects are trivial at a small scale, but at millions, latency and cost become real systems problems.
Previously on Vercel, redirects were handled by routing rules and middleware. Routing rules support up to 2,000 complex redirects with wildcards, and they function as an ordered list evaluated in sequence. Each rule may involve regex matching, meaning a single request could trigger many expensive evaluations. This is acceptable for a few thousand routing rules, but as counts grow, per-request work increases linearly.
Middleware offers more flexibility, but it adds latency by running extra code on every request. To serve millions of redirects with low latency, we needed a dedicated lookup path with near-constant or logarithmic time per request. Building on our previous work to make global routing faster with Bloom filters, we found a way to scale to millions of redirects.
What we optimized for
Scale:
Support millions of static redirects per project
Runtime behavior:
No additional latency cost for projects that don't configure redirects
A fast "no redirect" path, since most requests won't be redirected
Low process memory usage, relying on external storage and caching layers instead
Engineering values:
Simplicity and debuggability over premature optimization
Evolve iteratively rather than trying to get it perfect on the first try
With those goals in mind, we started with the simplest design we could think of, combining the redirects and Bloom filter in a single file. Since the redirect data was already JSON, and our Bloom filters already supported JSON exporting, we decided to use the JSONL file format to store this information.
JSON and Bloom filters versus napkin math
A Bloom filter is a probabilistic data structure that tests whether an element is a member of a set. Bloom filters can return false positives but never false negatives, so they answer "definitely not in the set" or "maybe in the set." By checking a small, cached Bloom filter first, we could skip the redirect lookup entirely for requests that don't match, keeping the common "no redirect" path extremely cheap. Only on a positive match would we parse the JSON file.
Simple, but would it scale? The napkin math said no. A million redirects could easily produce a file in the hundreds of megabytes, and fetching and parsing something that large would blow our latency and memory budgets. We needed to avoid loading the entire dataset at once.
Sharding and Bloom filters keep memory low and lookups fast
The fix was sharding. Instead of one massive JSONL file, we hashed the redirect path to distribute entries across many small shards. This allows us to load a small slice of data for a specific request, which shifts the burden from process memory to external storage and the file system cache. The Bloom filter still sits in front, short-circuiting the lookup for the vast majority of traffic. But now, when a request does pass the Bloom filter, we only need to fetch and parse a single small shard rather than the entire redirect set.
Shard structure
Each shard contains 3 parts:
A header line that encodes the properties of the Bloom filter
The base64 encoded Bloom filter
A JSON object of redirects, keyed by src path
Here is a sample:
At build time, we generate all of the shards and their Bloom filters and upload them to external storage. At runtime, the server only needs to know which dataset and shard count apply to a given project or deployment when it receives a request.
The lookup path checks the Bloom filter before parsing JSON
At request time, the bulk redirect lookup works like this:
Check whether the project or deployment has bulk redirects configured. If not, skip everything and proceed as usual.
Compute the redirect key from the incoming request and hash it to determine the shard.
Retrieve the shard from the cache or origin, and check the Bloom filter.
If the key is not present in the Bloom filter, we do not parse the JSON body of the shard.
If the key is maybe present in the Bloom filter, we load the JSON body of the shard and look up the exact redirect inside that object.
This design has some nice properties:
Fast negative lookups: Bloom filters are very fast and can be tuned to have a very low false positive rate
Human‑readable shards: Shards are just JSONL files. If something goes wrong, it's easy to dump a shard and see exactly what it contains
Low implementation risk: JSON parsing and Bloom filters are simple, so this can ship quickly, allowing us to gather real‑world data
JSON parsing became a bottleneck on positive lookups
We suspected JSON parsing might become a bottleneck, and our dogfooding confirmed it. When the Bloom filter indicated a redirect might exist, parsing the full JSON body for the relevant shard took considerable time. We also saw massive latency spikes under high CPU load, since JSON parsing is CPU-intensive and competes for resources with everything else on the node.
Reducing shard size would help with parsing speed, but smaller shards increase cardinality (the number of shards to manage) and cache miss rates. This created a trade-off. Large shards meant higher CPU overhead from parsing, while small shards meant more I/O latency from cache misses. We needed a data format that could retrieve a single value without parsing the entire shard.
Binary search over sorted keys to avoid parsing the entire shard
Instead of storing redirects in a JSON blob, we implemented a binary search keyed by the redirect path. Each shard stores its redirect keys in sorted order, so we can perform a logarithmic-time search over those keys. Once we find the key, we only need to parse the JSON for that specific redirect. This sidesteps the shard size problem entirely. Lookup cost no longer scales with the total amount of data in the shard, so we can keep shards large enough for good cache hit rates without paying for full JSON parsing.
Latency dropped and the spikes disappeared
With JSON parsing out of the hot path for positive lookups, requests for redirects that actually exist became both faster and more predictable.
The most visible improvement was the elimination of the latency spikes we had seen under high CPU load. When parsing a full JSON shard, redirect lookups competed for CPU time with everything else running on the node. With binary search, the per-request CPU cost dropped low enough that resource contention stopped being a factor.
Designing for the common case
Redirects themselves are simple. The challenge comes from combining that simple abstraction with large, mostly cold datasets and strict latency expectations at the edge. Routing rules were the wrong tool for this job.
Instead, we built a dedicated path for bulk redirects:
Shard redirect data so each piece stays small
Use Bloom filters so the common "no redirect" case stays cheap
Store redirects in a layout that supports binary search over keys
This development cycle reinforced a principle we keep coming back to. Avoid premature optimization. By starting with a simple, debuggable implementation and instrumenting it, we let production data dictate where complexity was actually needed.
Get started with bulk redirects
Bulk redirects are available for Pro and Enterprise customers, configurable via project configuration, the dashboard, API, or CLI. The current limit is 1 million redirects per project. If you need more capacity, reach out to us.
Plan
Included redirects
Additional capacity
Pro
1,000 per project
$50/month per 25,000
Enterprise
10,000 per project
$50/month per 25,000
Use bulk redirects to manage large-scale migrations, fix broken links, handle expired pages, and more. See our bulk redirects documentation or the getting started guide.
Read more
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み