URL 短縮サービスは週末のプロジェクトのように見えます。長い URL を受け取り、短いコードを返し、誰かがクリックしたらリダイレクトするだけです。だからこそ、多くのシステム設計面接で取り上げられます。小さな表面の下には、ID 生成、キャッシュ、HTTP のセマンティクス、分析についての本物の判断が隠れており、そのどれもがアーキテクチャを変えます。
この記事では、URL 短縮サービスを端から端まで設計します。まず数字から始め、衝突せずに短縮コードを生成する方法を見て、301 か 302 かの問題に決着をつけ、そのまま流用できるアーキテクチャ図で締めくくります。
要件と数字
機能要件:
- 長い URL の短縮リンクを作成する。任意でカスタムエイリアスも指定できる。
- 短縮リンクを元の長い URL にリダイレクトする。
- リンクごとのクリック数を数える。
- 求められたらリンクを期限切れにする。
非機能要件:リダイレクトは高速で高可用性であること、リンクが非公開になりうるなら短縮コードを順番に推測できないこと、そしてシステムがフィッシングページの無料リダイレクターにならないこと。
ざっくりした見積もりで、全体の形がはっきりします。
| 前提 | 値 | 1 秒あたり |
|---|---|---|
| 月間の新規リンク | 1 億 | 約 40 書き込み/秒 |
| 月間のリダイレクト | 100 億 | 約 3,900 読み取り/秒 |
| 読み取りと書き込みの比率 | 100:1 | |
| レコードサイズ | 約 500 バイト | |
| 5 年分のストレージ | 60 億リンク | 約 3 TB |
読み取りは書き込みより 2 桁多いので、キャッシュもレプリカも注意も読み取り経路に向けます。書き込み経路は小さく、単純なままで構いません。
API
製品を支えるエンドポイントは 2 つです。
POST /links
Content-Type: application/json
{"url": "https://example.com/very/long/path", "alias": "launch", "expiresAt": "2027-01-01T00:00:00Z"}
201 Created
{"code": "launch", "shortUrl": "https://sho.rt/launch"}
GET /launch
302 Found
Location: https://example.com/very/long/path
この 2 つは別々のサービスに分けます。リダイレクトサービスはステートレスで読み取り専用なので、単独でスケールでき、デプロイの頻度も少なくて済みます。
短縮コードの生成
短縮コードは base62(a–z、A–Z、0–9)で表した数値です。7 文字なら 62^7、約 3.5 兆通りのコードになり、6 文字なら約 568 億通りです。月 1 億リンクのペースなら、7 文字で数千年もちます。
本当の問題は、その数値をどこから得るかです。
| 方式 | 仕組み | 注意点 |
|---|---|---|
| カウンター + base62 | 1 つのグローバルなシーケンスをエンコード | 競合が 1 か所に集中する。コードが連番で推測できる |
| ID 範囲 | アロケーターが API インスタンスごとに ID のブロック(例:100,000 個)を渡す | インスタンスが落ちると欠番が出る。ブロック内では連番のまま |
| ハッシュ + 切り詰め | URL をハッシュし、先頭の base62 7 文字を使う | 衝突する。確認と再試行が必要 |
| Snowflake 方式の ID | タイムスタンプ + マシン ID + シーケンスを 64 ビットに収める | コードが長くなる(base62 で 11 文字) |
同じ URL には常に同じコードが付くので、ハッシュは魅力的に見えます。落とし穴は誕生日問題です。3.5 兆通りのコードがあっても、わずか約 220 万リンクで少なくとも 1 回衝突する確率が 50% を超えます。そうなると挿入のたびに一意性の確認とソルトを変えた再試行が必要になり、1 回の書き込みが読み取りと書き込みの 2 回に変わります。
現実的な既定は ID 範囲です。各 API インスタンスは小さなトランザクションでアロケーターからブロックを予約し、あとは調整なしでメモリからコードを払い出します。連番のコードを避けたいなら、エンコードする前に ID を可逆な置換(たとえば ID 空間上のブロック暗号)に通します。Twitter の Snowflake はもう 1 つの道を示しています。41 ビットのタイムスタンプ、10 ビットのマシン ID、12 ビットのシーケンスで、中央のアロケーターなしに一意な 64 ビット ID を作れます。その代わり、コードは長くなります。
カスタムエイリアスはジェネレーターを通りません。同じテーブルに保存し、コード列の一意制約に頼って重複をはじきます。
301 か 302 か?
リダイレクトのステータスコードは RFC 9110 で定義されています。ここで重要な違いはキャッシュです。301(Moved Permanently)のレスポンスはヒューリスティックにキャッシュできるため、ブラウザーはそれを覚えておき、次のクリックではサーバーを経由しないことがあります。302(Found)は一時的なもので、指定しない限りキャッシュされません。
- すべてのクリックを数える必要がある場合や、あとで転送先を変えたい場合は 302 を使います。
- リンクが一切変わらず、完全な分析よりリクエストの少なさを優先するなら 301 を使います。
307 と 308 は、302 と 301 のメソッドを保持する版です。普通のブラウザーのクリックは GET リクエストなので、どちらを選ぶかが問題になることはほとんどありません。
ストレージとキャッシュ
リダイレクトはすべてコードによる単一キーの検索なので、キー検索が速いストアなら何でも使えます。DynamoDB や Cassandra のようなキーバリューストアでも、コードを主キーにした Postgres でも構いません。1 ノードで足りなくなったらコードでシャーディングします。設計上どのコードも他より負荷が高いということはないので、コードのハッシュで負荷は均等に分散します。例外は人気のリンクで、それはキャッシュが吸収します。
ストアの前に Redis キャッシュを置き、cache-aside で読み取ります。Redis でコードを探し、ミスしたらデータベースにフォールバックし、TTL 付きでキャッシュに格納します。リンクの人気はロングテールに従うので、リンクのごく一部を保持するキャッシュでクリックの大半をさばけます。TTL、スタンピード、無効化については キャッシュ戦略の記事 で詳しく扱っています。リードレプリカはフォールバック経路を助けますが、そのとき許容すべき遅延は データベースレプリケーションの記事 で説明しています。
Cache-Control ヘッダーを送れば、CDN やエッジキャッシュが人気のリダイレクトを直接返せます。ただしその場合、サーバーが一度も目にしないクリックにエッジが応答することになります。エッジでログを取るか、リダイレクトをキャッシュしないかのどちらかにします。
リダイレクトを遅らせないクリック分析
リダイレクト経路でクリック数を同期的に書き込んではいけません。リダイレクトサービスは小さなクリックイベント(コード、タイムスタンプ、リファラー、国)を Kafka のようなログに発行し、すぐに応答を返します。コンシューマーがリンクごと・日ごとに件数を集計し、分析用ストアに書き込みます。分析側が遅れても、リダイレクトは動き続けます。これは イベント駆動アーキテクチャの記事 で説明している疎結合と同じです。
アーキテクチャ図
ダイアグラムの内容
- User
- Edge / CDN
- Load balancer
- Redirect service
- Shorten API
- Redis: code to URL
- Links store
- ID allocator
- URL reputation API
- Click events
- Click aggregator
- Analytics store
- GET /{code}
- POST /links
- 1. lookup
- 2. on miss
- insert code and URL
- ID range
- check the target
- click event
- consume
- daily counts
図の読み方は次のとおりです。
- 読み取り経路(上)。
GET /{code}のリクエストはエッジとロードバランサーを通ってリダイレクトサービスに届きます。サービスはまず Redis を確認し、ミスしたときだけリンクストアを読みます。 - 書き込み経路(下)。
POST /linksは短縮 API に届きます。API は URL 評判サービスで転送先を確認し、アロケーターから予約した範囲の ID を取り、base62 でエンコードしてリンクを書き込みます。 - 分析(右)。 クリックイベントは非同期なので破線です。アグリゲーターがそれを日次の件数に変えます。
- Markdown ブロック は容量の数字を設計の隣に置き、レビュアーが確認できるようにしています。
悪用、有効期限、カスタムエイリアス
公開の短縮サービスは、止めない限りフィッシングの道具になります。新しい転送先を Google Safe Browsing のような評判サービスで確認し、アカウントと IP ごとにリンク作成をレート制限し、コードをすぐに無効化できる手段を用意します。きれいだったドメインがあとから悪意あるものに変わることもあるので、古いリンクも定期的に確認し直します。
有効期限は、リンクと一緒に expiresAt を保存してリダイレクト時に確認し、期限切れの行はバックグラウンドジョブで削除します。期限切れのコードにはリダイレクトせず、404 か 410 を返します。
図に載せるべきこと
- 読み取り経路と書き込み経路を分ける。 スケールの仕方が違うので、図にもそれを表します。
- リダイレクトのステータス(301 か 302)をリダイレクトサービスに書く。分析が完全かどうかはそれで決まります。
- ID の出どころを示す。 箱の中の「コードを生成」という文字は、この設計で最も議論になる判断を隠してしまいます。
- 非同期のフローは破線にする。 分析がリダイレクトを遅らせられないことが、ひと目でわかるようにします。
- 数字を書く。 秒あたりの書き込み数とストレージ容量のない設計はレビューできません。
システム設計図の例 では、同じ約束事をより大きなシステムに当てはめています。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- ユーザー用に C4 モデルパックから 人物 を、クラウドパックから CDN と クラウドロードバランサー を追加します。
- C4 モデルパックから コンテナ ノードを 4 つ追加します。リダイレクトサービス、短縮 API、ID アロケーター、クリックアグリゲーターです。それぞれに技術と 1 行の説明を書きます。
- データパックから Redis、Kafka、データウェアハウス を、リンクストア用に C4 モデルパックの データベース を、URL 評判 API 用に 外部システム を追加します。
- 読み取り経路を上に、書き込み経路を下につなぎ、クリックイベントは破線のエッジにして、容量の数字を書いた基本パックの Markdown ブロックを追加します。
この例で使うダイアグラムノードは 13 個で、Free プランの範囲に収まります。
よくある質問
短縮コードは何文字にすべきですか? base62 で 7 文字なら約 3.5 兆通りのコードになり、月 1 億リンクのペースでも数十年もちます。7 文字で始め、あとで 8 文字に伸ばせるようにしておきます。設計のどこも固定長に依存していません。
長い URL をハッシュしてコードを作るべきですか? 単純な書き込みより重複排除が大事な場合だけです。切り詰めたハッシュは早い段階で衝突します(7 文字では 220 万リンクを超えると、少なくとも 1 回衝突する確率のほうが高くなります)。そのため挿入のたびに確認と再試行が必要です。
URL 短縮サービスには 301 と 302 のどちらがよいですか? すべてのクリックを数える必要がある場合や、転送先を変える可能性がある場合は 302 です。301 はブラウザーにリダイレクトをキャッシュさせるので、リクエストは減りますが、繰り返しのクリックが分析から見えなくなります。
リンクはどのデータベースに保存すべきですか? 主キーで速く検索できるストアなら何でも構いません。この規模なら Postgres で長く足ります。書き込みを多数のノードにシャーディングする必要が出てきたら、キーバリューストアが魅力的になります。
リンクを推測されないようにするには? 連番の ID を外に出さないことです。エンコードの前に ID を置換するか、一意性を確認したうえでランダムなコードを使います。そして、短縮リンクを非公開コンテンツの秘密として扱ってはいけません。