設計問題: URL短縮サービス — bit.ly を設計する
「シンプルに見える、だから難しい」
面接の前日夜、ソウタはカフェのカウンター席でノートを広げていた。転職活動を始めて2ヶ月。コーディングテストは通過できるようになってきたが、システム設計面接だけがまだ怖かった。
「bit.lyを設計してください」という問いがあると聞いていた。URLを短くするだけ。何が難しいのか。
「そういう油断が一番危ない」とレイカが翌朝言った。「シンプルに見える問題ほど、採点ポイントが多い。URL短縮は面接の定番中の定番。しっかり体系立てて答えられないと、一発でレベルが見えてしまう。」
「どんな落とし穴があるんですか?」
「ハッシュの衝突問題、リダイレクトのHTTPセマンティクス、キャッシュの一貫性、有効期限の管理、大量書き込みのボトルネック……全部を一問に詰め込んである。逆に言えば、全部答えられると非常に印象が良い。」
ソウタは深く息を吸った。今日は完璧に仕上げる。
Step 1: 要件の確認
「面接ではまず要件を確認する。自分の仮定を声に出して確認すること。」
ソウタ:「いくつか確認させてください。
【機能要件】
- URLの短縮: 長いURLを短いURLに変換する
- リダイレクト: 短いURLにアクセスしたら元のURLに転送する
- カスタムエイリアス(bit.ly/mylink)は必要ですか? → No
- URL有効期限の設定は必要ですか? → Yes、デフォルト5年
- クリック統計(分析機能)は? → シンプルなクリック数のみ
- 削除機能は? → 管理者のみ
【非機能要件】
- 1日の短縮URL作成数: 1億(100M)
- 読み書き比率: 10:1(リダイレクトが多い)
- 高可用性: 99.9%以上(年間8.7時間までのダウン許容)
- リダイレクトのレイテンシ: P99で100ms以下
- データ保持期間: 10年」
INFO
要件確認で見落としがちな点は「削除機能」と「カスタムエイリアス」の有無。カスタムエイリアスがあると衝突チェックの複雑さが増し、削除機能があるとキャッシュの無効化戦略が変わる。面接冒頭でこれを聞けると、設計の全体像が固まる。
Step 2: 規模の概算
「次に数字を出す。概算は暗算で出せるレベルまで練習しておく。」
【書き込み QPS(URL作成)】
100M / day ÷ 86,400 sec ≒ 1,160 URLs/sec ≒ 1,200/sec
【読み込み QPS(リダイレクト)】
1,200 × 10 = 12,000 redirects/sec
ピーク(3倍): 36,000 redirects/sec
【ストレージ計算(1レコードあたり)】
short_code: 7 bytes
original_url: 平均 2,048 bytes
user_id: 8 bytes
created_at: 8 bytes
expires_at: 8 bytes
click_count: 8 bytes
合計: 約 2,100 bytes ≒ 2 KB
【総ストレージ】
1日: 100M × 2KB = 200 GB/day
10年: 200GB × 365 × 10 ≒ 730 TB
【帯域幅】
書き込み: 1,200 × 2KB ≒ 2.4 MB/s
読み込み: 12,000 × 100B(短いレスポンス)≒ 1.2 MB/s
WARNING
「730TB」という数字を出すと面接官は「1台のDBに入りますか?」と聞いてくる。PostgreSQL単体では管理できないため、パーティショニングやシャーディング戦略が必要。この問いを先読みして自分から言えると評価が上がる。
Step 3: 高レベル設計
APIの設計
# POST /api/v1/urls — URL短縮
# Request:
# { "original_url": "https://www.example.com/very/long/path" }
# Response:
# { "short_url": "https://bit.ly/abc1234", "expires_at": "2029-06-01T00:00:00Z" }
# GET /:short_code — リダイレクト
# Response: 302 Redirect to original_url
# GET /api/v1/urls/:short_code/stats — 統計取得
# Response: { "short_code": "abc1234", "click_count": 12345, "created_at": "..." }Railsでの基本実装
# app/models/short_url.rb
class ShortUrl < ApplicationRecord
validates :original_url, presence: true
validates :short_code, presence: true, uniqueness: true
validates :expires_at, presence: true
scope :active, -> { where('expires_at > ?', Time.current) }
scope :expired, -> { where('expires_at <= ?', Time.current) }
def expired?
expires_at < Time.current
end
end
# app/controllers/api/v1/urls_controller.rb
class Api::V1::UrlsController < ApplicationController
before_action :authenticate_api_key!
before_action :check_rate_limit!
def create
result = UrlShortenerService.call(
original_url: params[:original_url],
user_id: current_user.id
)
render json: result, status: :created
rescue UrlShortenerService::InvalidUrlError => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
# app/controllers/redirect_controller.rb
class RedirectController < ApplicationController
def show
original_url = UrlResolverService.resolve(params[:short_code])
ClickTrackingJob.perform_later(params[:short_code])
redirect_to original_url, status: :found, allow_other_host: true
rescue UrlResolverService::NotFoundError
render file: 'public/404.html', status: :not_found
rescue UrlResolverService::ExpiredError
render file: 'public/410.html', status: :gone
end
endStep 4: 詳細設計 — 短縮コードの生成
「ここが一番重要なポイント」とレイカが言った。「どうやって7文字の短縮コードを生成するか。方式が3つある。」
方式1: MD5ハッシュ(非推奨)
def generate_by_hash(url)
Digest::MD5.hexdigest(url)[0, 7]
end
# 問題点:
# 1. 同じURLから常に同じコードが生成される
# → 2人が同じURLを短縮すると同じコードになり、統計が混在する
# 2. 衝突率: 7文字(16進)では 16^7 = 2.68億パターンしかない
# → 100Mレコードで衝突が起きる確率が無視できない方式2: ID + Base62エンコード(推奨)
Base62の数学的説明:
Base62 = {0-9, a-z, A-Z} の62文字でIDをエンコードする
10進数 → Base62の変換例:
12345 ÷ 62 = 199 余り 7 → '7'
199 ÷ 62 = 3 余り 13 → 'd'
3 ÷ 62 = 0 余り 3 → '3'
→ 12345 = "3d7"(逆順に読む)
7文字でカバーできる最大数:
62^7 = 3,521,614,606,208 ≒ 3.5兆パターン
10年間の作成予定: 100M × 365 × 10 = 3,650億
→ 3.5兆 > 3,650億なので7文字で十分!
なぜBase64でなくBase62か?
Base64の追加文字 '+' と '/' はURLで問題が起きる
Base62はURLセーフな文字のみを使用
# app/services/base62_encoder.rb
class Base62Encoder
CHARS = ('0'..'9').to_a + ('a'..'z').to_a + ('A'..'Z').to_a
BASE = CHARS.length # 62
def self.encode(num)
return CHARS[0] if num == 0
result = []
while num > 0
result.unshift(CHARS[num % BASE])
num /= BASE
end
result.join
end
def self.decode(str)
str.chars.reduce(0) do |acc, char|
acc * BASE + CHARS.index(char)
end
end
end
# Base62Encoder.encode(1) #=> "1"
# Base62Encoder.encode(62) #=> "10"
# Base62Encoder.encode(999999) #=> "4c91"方式3: Snowflake形式の分散ID生成(本番向け)
# app/services/snowflake_id_generator.rb
# 41bit:タイムスタンプ | 10bit:ノードID | 12bit:シーケンス
class SnowflakeIdGenerator
EPOCH = 1_704_067_200_000 # 2024-01-01 00:00:00 UTC (ms)
NODE_BITS = 10
SEQ_BITS = 12
MAX_SEQ = (1 << SEQ_BITS) - 1 # 4095
MAX_NODE = (1 << NODE_BITS) - 1 # 1023
def initialize(node_id)
raise ArgumentError unless (0..MAX_NODE).cover?(node_id)
@node_id = node_id
@sequence = 0
@last_ts = -1
@mutex = Mutex.new
end
def next_id
@mutex.synchronize do
ts = current_ms
if ts == @last_ts
@sequence = (@sequence + 1) & MAX_SEQ
ts = wait_for_next_ms(@last_ts) if @sequence == 0
else
@sequence = 0
end
@last_ts = ts
((ts - EPOCH) << (NODE_BITS + SEQ_BITS)) |
(@node_id << SEQ_BITS) |
@sequence
end
end
private
def current_ms = (Time.now.to_f * 1000).to_i
def wait_for_next_ms(last)
ts = current_ms
ts <= last ? wait_for_next_ms(last) : ts
end
end
GENERATOR = SnowflakeIdGenerator.new(ENV.fetch('NODE_ID').to_i)
short_code = Base62Encoder.encode(GENERATOR.next_id)
# => "2nK7xQp" のような7文字Golang での実装
// internal/shortcode/generator.go
package shortcode
import (
"sync"
"time"
)
const (
epoch = int64(1704067200000)
nodeBits = 10
seqBits = 12
maxSeq = (1 << seqBits) - 1
)
type Generator struct {
mu sync.Mutex
nodeID int64
sequence int64
lastMs int64
}
func NewGenerator(nodeID int64) *Generator {
return &Generator{nodeID: nodeID}
}
func (g *Generator) Next() uint64 {
g.mu.Lock()
defer g.mu.Unlock()
ms := time.Now().UnixMilli()
if ms == g.lastMs {
g.sequence = (g.sequence + 1) & maxSeq
if g.sequence == 0 {
for ms <= g.lastMs {
ms = time.Now().UnixMilli()
}
}
} else {
g.sequence = 0
}
g.lastMs = ms
return uint64((ms-epoch)<<(nodeBits+seqBits) | g.nodeID<<seqBits | g.sequence)
}
// internal/shortcode/base62.go
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func Encode(num uint64) string {
if num == 0 {
return string(chars[0])
}
var result []byte
for num > 0 {
result = append([]byte{chars[num%62]}, result...)
num /= 62
}
return string(result)
}Step 5: データベーススキーマの詳細設計
-- short_urls テーブル(月単位パーティショニング)
CREATE TABLE short_urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(10) NOT NULL,
original_url TEXT NOT NULL,
user_id BIGINT NOT NULL,
click_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
deleted_at TIMESTAMPTZ,
is_malicious BOOLEAN NOT NULL DEFAULT FALSE
) PARTITION BY RANGE (created_at);
-- インデックス設計
CREATE UNIQUE INDEX idx_short_urls_short_code
ON short_urls(short_code)
WHERE deleted_at IS NULL;
CREATE INDEX idx_short_urls_user_id
ON short_urls(user_id, created_at DESC);
CREATE INDEX idx_short_urls_expires_at
ON short_urls(expires_at)
WHERE expires_at < NOW();
-- 月単位パーティション(自動生成スクリプト推奨)
CREATE TABLE short_urls_2024_01 PARTITION OF short_urls
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE short_urls_2024_02 PARTITION OF short_urls
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');INFO
パーティショニングを月単位にする理由は、期限切れURLの一括削除が高速になるため。古い月のパーティションを DROP TABLE するだけで良く、DELETE 文によるロックや VACUUM のオーバーヘッドを避けられる。面接でこのトレードオフを言えると高評価につながる。
Step 6: キャッシュ戦略の詳細
キャッシュヒット率の計算
前提:
- 総URL数: 3,650億(10年分)
- 上位20%のURLが80%のアクセスを受ける(パレートの法則)
- 人気URLのキャッシュに必要なメモリ:
730TB × 20% ÷ 10年 = 約15GB/年
→ 最新1年分: 15GB(cache.r6g.xlargeの32GBで余裕あり)
キャッシュヒット率:
- 目標: 95%(12,000 req/sec のうち 11,400 をキャッシュで処理)
- DB負荷: 12,000 × 5% = 600 req/sec(RDSで十分対応可)
UrlResolverService の実装
# app/services/url_resolver_service.rb
class UrlResolverService
class NotFoundError < StandardError; end
class ExpiredError < StandardError; end
HOT_THRESHOLD = 500 # 1分間のアクセス数
def self.resolve(short_code)
# 1. Redisキャッシュを確認
cached = Rails.cache.read(cache_key(short_code))
return handle_cached(short_code, cached) if cached
# 2. キャッシュミス → DBから取得
record = ShortUrl.find_by(short_code: short_code, deleted_at: nil)
raise NotFoundError unless record
raise ExpiredError if record.expired?
# 3. ホットURL判定でTTLを動的変更してキャッシュに書き込み
ttl = calculate_ttl(short_code)
Rails.cache.write(cache_key(short_code), {
url: record.original_url,
expires_at: record.expires_at.to_i
}, expires_in: ttl)
record.original_url
end
private
def self.handle_cached(short_code, cached)
raise ExpiredError if cached[:expires_at] < Time.current.to_i
increment_hot_counter(short_code)
cached[:url]
end
def self.calculate_ttl(short_code)
count = $redis.get("hot:#{short_code}").to_i
count > HOT_THRESHOLD ? 24.hours : 1.hour
end
def self.increment_hot_counter(short_code)
key = "hot:#{short_code}"
$redis.multi do |tx|
tx.incr(key)
tx.expire(key, 60)
end
end
def self.cache_key(short_code) = "url:v1:#{short_code}"
endキャッシュウォームアップ
# lib/tasks/cache_warmup.rake
namespace :cache do
desc "上位アクセスURLをキャッシュにプリロード(デプロイ直後のコールドスタート対策)"
task warmup: :environment do
puts "Starting cache warmup..."
top_urls = ShortUrl.active
.order(click_count: :desc)
.limit(10_000)
.select(:short_code, :original_url, :expires_at)
top_urls.each_slice(100) do |batch|
batch.each do |record|
Rails.cache.write(
"url:v1:#{record.short_code}",
{ url: record.original_url, expires_at: record.expires_at.to_i },
expires_in: 24.hours
)
end
sleep(0.01) # Redis過負荷防止
end
puts "Warmed up #{top_urls.size} URLs"
end
endStep 7: 301 vs 302 リダイレクトのトレードオフ
「面接で必ず聞かれる」とレイカが強調した。「301と302、どちらを使うか?」
【301 Moved Permanently(恒久的リダイレクト)】
メリット:
- ブラウザが宛先URLをキャッシュする
- 2回目以降はサーバーにリクエストが来ない → サーバー負荷が激減
デメリット:
- クリック数を正確に計測できない(サーバーに来ないから)
- 転送先URLを変えたくても、クライアントはキャッシュを使い続ける
【302 Found(一時的リダイレクト)】
メリット:
- 毎回サーバーを経由するため、クリック数を正確に計測できる
- 転送先URLを後から変更できる(A/Bテスト、リンクの更新)
デメリット:
- 毎回サーバーにリクエストが来るため負荷が高い
【今回の判断】
クリック統計が要件にあるため → 302 を採用
ただしElastiCacheキャッシュでDB負荷を緩和する
# クリックカウントは非同期で更新(レイテンシ悪化を防ぐ)
class ClickTrackingJob < ApplicationJob
queue_as :default
sidekiq_options retry: 3
def perform(short_code)
ShortUrl.where(short_code: short_code)
.update_all('click_count = click_count + 1')
end
endStep 8: APIレート制限の実装
# app/services/rate_limiter.rb
class RateLimiter
class TooManyRequestsError < StandardError; end
# Fixed Window方式: シンプルだが窓の境界で2倍のリクエストが通る問題がある
def self.check!(user_id, limit: 100, window: 60)
key = "rate:#{user_id}:#{Time.current.to_i / window}"
count = $redis.incr(key)
$redis.expire(key, window * 2) if count == 1
raise TooManyRequestsError if count > limit
end
# Sliding Window方式(より正確、Redisの sorted set を利用)
def self.check_sliding!(user_id, limit: 100, window: 60)
key = "rate_sw:#{user_id}"
now = Time.current.to_f
results = $redis.multi do |tx|
tx.zremrangebyscore(key, 0, now - window)
tx.zadd(key, now, "#{now}-#{SecureRandom.hex(4)}")
tx.zcard(key)
tx.expire(key, window)
end
raise TooManyRequestsError if results[2] > limit
end
endStep 9: セキュリティ対策 — 悪意あるURLフィルタリング
# app/services/url_safety_checker.rb
class UrlSafetyChecker
BLOCKED_DOMAINS = %w[evil.com malware.net phishing.org].freeze
def self.safe?(url)
uri = URI.parse(url)
# 1. プロトコルチェック(http/httpsのみ)
return false unless %w[http https].include?(uri.scheme)
# 2. 内部IPアドレスをブロック(SSRF対策)
return false if internal_ip?(uri.host)
# 3. ブロックリスト照合
return false if BLOCKED_DOMAINS.any? { |d| uri.host&.end_with?(d) }
# 4. Google Safe Browsing API(本番実装)
check_safe_browsing_api(url)
rescue URI::InvalidURIError
false
end
private
def self.internal_ip?(host)
return false unless host
ip = IPAddr.new(host) rescue return(false)
ip.private? || ip.loopback? || ip.link_local?
end
def self.check_safe_browsing_api(url)
# Google Safe Browsing Lookup API v4 を使用
# レスポンスの matches が空なら安全
true
end
endStep 10: AWSアーキテクチャの詳細
CloudFront:
オリジン: ALB
キャッシュ: 302レスポンスはキャッシュしない
WAF統合: レート制限、SQLインジェクション防御
カスタムドメイン: ACM証明書
ALB:
TargetGroup1: URL短縮 ECS × 3タスク(パス /api/*)
TargetGroup2: リダイレクト ECS × 10タスク(パス /*)
ECS Fargate - URL短縮:
CPU: 0.5vCPU / Memory: 1GB
AutoScaling: CPU70%超で最大10タスク
タスク定義: Rails + Puma(4ワーカー)
ECS Fargate - リダイレクト:
CPU: 0.25vCPU / Memory: 512MB
AutoScaling: CPU60%超で最大30タスク
キャッシュヒット時はDBアクセスなし
Aurora PostgreSQL:
構成: Writer 1台 + Reader 2台(Multi-AZ)
インスタンス: db.r6g.xlarge(4vCPU, 32GB)
バックアップ: 自動スナップショット35日保持
ElastiCache Redis Cluster:
ノード: cache.r6g.xlarge × 3(Master1 + Replica2)
メモリ: 32GB × 3 = 96GB(約15GB使用予定)
クラスターモード: 有効(シャーディング)
SQS + Lambda(クリック集計):
バッチサイズ100でDBに集計書き込み
リダイレクトのレイテンシからDB書き込みを分離Step 11: ボトルネック分析とスケーリング戦略
【ボトルネック1: データベースの書き込み】
問題: 1,200 writes/sec はAurora Writerの上限に近い
対策:
- SidekiqジョブでClickCountの非同期書き込み
- PgBouncerによるコネクションプール最適化
- 将来的にはCQRS(書き込みと読み込みを完全分離)
【ボトルネック2: ホットなURL】
問題: 有名人のbit.lyがバズると特定URLに数万req/sec
対策:
- ElastiCacheのキャッシュTTLを動的に延長(1時間→24時間)
- CloudFrontエッジキャッシュで世界中のPoPにキャッシュ
【ボトルネック3: ノードIDの管理(Snowflake)】
問題: ノードIDが重複するとIDが衝突する
対策:
- etcdまたはRedisでノードIDを払い出す
- ECSタスク起動時に動的にノードIDを取得
// internal/nodeid/allocator.go
// etcdでノードIDをECSタスク起動時に払い出し
package nodeid
import (
"context"
"fmt"
clientv3 "go.etcd.io/etcd/client/v3"
)
type Allocator struct {
client *clientv3.Client
}
func (a *Allocator) Acquire(ctx context.Context) (int64, error) {
for id := int64(0); id < 1024; id++ {
key := fmt.Sprintf("/shorturl/nodes/%d", id)
lease, _ := a.client.Grant(ctx, 30)
txn := a.client.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision(key), "=", 0)).
Then(clientv3.OpPut(key, "locked", clientv3.WithLease(lease.ID))).
Commit()
if resp, err := txn; err == nil && resp.Succeeded {
go a.keepAlive(ctx, lease.ID)
return id, nil
}
}
return 0, fmt.Errorf("no available node IDs")
}
func (a *Allocator) keepAlive(ctx context.Context, leaseID clientv3.LeaseID) {
ch, _ := a.client.KeepAlive(ctx, leaseID)
for range ch {}
}Step 12: 監視・メトリクス設計
# app/middleware/metrics_middleware.rb
class MetricsMiddleware
REDIRECT_LATENCY = Prometheus::Client::Histogram.new(
:redirect_latency_seconds,
docstring: 'Redirect latency in seconds',
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0]
)
CACHE_HITS = Prometheus::Client::Counter.new(
:cache_hits_total, docstring: 'Cache hits'
)
CACHE_MISSES = Prometheus::Client::Counter.new(
:cache_misses_total, docstring: 'Cache misses'
)
def initialize(app)
@app = app
end
def call(env)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
REDIRECT_LATENCY.observe(duration)
[status, headers, body]
end
end# CloudWatch アラーム設定
アラーム一覧:
RedirectLatencyP99:
メトリクス: p99(redirect_latency_seconds)
閾値: 100ms超
アクション: PagerDuty通知
CacheHitRateLow:
メトリクス: cache_hits / (cache_hits + cache_misses)
閾値: 90%未満
アクション: Slack通知
DBConnectionsHigh:
メトリクス: RDS DatabaseConnections
閾値: 最大接続数の80%超
アクション: ECSタスクスケールダウン + Slack通知
SQSQueueDepth:
メトリクス: ApproximateNumberOfMessages
閾値: 10,000件超(クリック集計の遅延)
アクション: Lambda同時実行数を増やす面接官との深掘り会話
面接官: 「DBのauto_incrementを使えばシンプルじゃないですか?」
ソウタ: 「単一DBであれば有効です。ただし今回の規模では
将来的にDBをシャーディングする可能性があります。
その場合、auto_incrementはグローバルに一意でなくなります。
Snowflake IDは時刻ベースのため、どのDBノードでも
衝突なしに生成でき、かつ時系列ソートも可能です。」
---
面接官: 「カスタムエイリアスが必要になったら設計をどう変えますか?」
ソウタ: 「テーブルにcustom_aliasカラムを追加し、
短縮コードと同じくユニークインデックスを設定します。
ただし予約語(api, admin, healthなど)は
ブロックリストで除外します。
長さも最大20文字などの制約を設けて、
Snowflakeのコードと衝突しないようにします。」
---
面接官: 「悪意あるURLを短縮しようとしたらどう防ぎますか?」
ソウタ: 「3層のフィルタリングを実施します。
1. URLスキームチェック(http/httpsのみ、内部IPブロック)
2. 既知の悪意あるドメインのブロックリスト照合
3. Google Safe Browsing APIによるリアルタイムチェック
URLが作成されてからフィッシングサイトになるケースには
定期的な再チェックジョブで対応します。」
ソウタの振り返り
翌日の面接後、ソウタはレイカにメッセージを送った。
「URL短縮の問題が出ました。Snowflakeの話をしたら、面接官が目を輝かせました。301 vs 302のトレードオフを先に言えた瞬間、『よく知っていますね』と言われました。」
「どうだった、設計全体の流れは?」
「要件確認から始めて、概算で数字を出して、高レベル設計を図で描いて、詳細設計でコードを書いて、最後にボトルネックを自分から指摘できました。」
「その順番を徹底できるだけで、面接の合格率は全然違う。シンプルに見えるURL短縮で、これだけの深さまで答えられたなら、次は1段難しい問題をやろう。」
INFO
URL短縮サービスは「面接版Hello World」とも呼ばれる定番問題。Base62エンコード、Snowflake ID、301 vs 302、キャッシュのホットキー対策という4つのポイントを流暢に話せるだけで、上位候補者の印象を与えられる。
次の章では、SNSのタイムライン設計に挑む。「ファンアウト」という概念が面接での肝になる。