mybook

設計問題: 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: 高レベル設計

Loading diagram...

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
end

Step 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
end

Step 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
end

Step 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
end

Step 9: セキュリティ対策 — 悪意あるURLフィルタリング

Loading diagram...
# 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
end

Step 10: AWSアーキテクチャの詳細

Loading diagram...
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: ボトルネック分析とスケーリング戦略

Loading diagram...
【ボトルネック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のタイムライン設計に挑む。「ファンアウト」という概念が面接での肝になる。