← Blog
·12 blog.minutes

すべての開発者が知っておくべきシステム設計の基礎

ロードバランシング、キャッシング、シャーディング、CDN、メッセージキュー、CAP定理、レート制限——実際に使える設定例とともに実世界の例でわかりやすく解説。

すべてのバックエンド開発者はいつか壁にぶつかる。アプリケーションはラップトップ上では問題なく動く。ステージングでも3人の同時ユーザーで問題なく動く。そして本番にデプロイし、1000人が同時にアクセスした瞬間、データベースはダウンする。APIは5xxエラーを返し始め、フロントエンドはハングし、Slackのどこかで午前2時にあなたの電話を振動させるスクリーンショットが投稿される。

システム設計とは、その壁の向こう側にあるものである。おもちゃのアプリケーションと、実際のトラフィックを処理できる本番システムを分ける、パターン、トレードオフ、インフラストラクチャ上の決定の集合体だ。良いニュースは、基礎を理解するためにシニアインフラエンジニアである必要はないということだ。約8つの概念と、それらがどのように相互作用し、いつそれぞれを使うべきかを知る必要がある。

この記事では、現役のバックエンド開発者にとって最も重要なシステム設計パターンを扱う。各セクションで概念を説明し、実践的な例を示し、採用する前に評価すべきトレードオフを説明する。これらは抽象的な教科書の概念ではなく、スケールする必要があるものを構築するたびに使うことになるツールである。

ロードバランシング——すべてのサーバーを忙しく、しかし忙しすぎない状態に保つ

ロードバランシングは、実装できる最もシンプルで最も影響力のあるシステム設計パターンである。考え方は単純だ:すべてのトラフィックを1台のサーバーに送るのではなく、着信リクエストをサーバーのプール全体に分散する。これにより2つのことが同時に得られる:高い可用性(1台のサーバーがダウンしても、他のサーバーがサービスを継続する)と高いスループット(複数のサーバーが作業を共有する)である。

最も一般的なロードバランシングアルゴリズムは、ラウンドロビン、最小接続数、IPハッシュである。ラウンドロビンはサーバーリストを順番に循環する——シンプルで予測可能だが、各サーバーが実際にどの程度ビジーかを認識しない。最小接続数は各リクエストを最もアクティブ接続数が少ないサーバーに送り、不均等なリクエスト負荷をより適切に処理する。IPハッシュはクライアントのIPアドレスを使用して決定論的にサーバーを選ぶ。これはスティッキーセッション——同じクライアントが常に同じサーバーにヒットすることを保証する——が必要な場合に重要である。

実際には、ほとんどの本番セットアップは組み合わせを使用する。レイヤー4ロードバランサー(TCPレベルで動作)が生の接続をリバースプロキシまたはAPIゲートウェイのプールに分配し、それらがレイヤー7ロードバランシング(HTTPレベルで動作)を実行して特定のアプリケーションインスタンスにリクエストをルーティングする。この階層化アプローチにより、データプレーンは高速に保ち、ルーティングロジックは柔軟に保つ。

# nginx.conf — simple round-robin load balancing across three app servers
upstream app_cluster {
    round-robin;
    server app1.internal:3000 max_fails=3 fail_timeout=30s;
    server app2.internal:3000 max_fails=3 fail_timeout=30s;
    server app3.internal:3000 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl;
    location / {
        proxy_pass http://app_cluster;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

ほとんどの開発者が見落とす重要な詳細はヘルスチェックである。ロードバランサーはどのサーバーが正常かを知っている場合にのみ有用である。app2がクラッシュしてもロードバランサーがトラフィックを送り続ければ、エラーレートは約3分の1急上昇する。常にアクティブヘルスチェックを設定する——ロードバランサーが定期的に各サーバーにpingを送り、応答しないサーバーをプールから自動的に削除する。

キャッシング——高速だが、その代償は?

キャッシングは、どの開発者にとっても最も高いレバレッジを持つパフォーマンス最適化である。1回のキャッシュヒットで、200ミリ秒のデータベースクエリを2ミリ秒のメモリルックアップに変えられる。桁違いの2桁の差である。数百万のリクエストにわたって適用すると、その差は月額1万円のデータベース請求書と500ドルの差になる。

標準的なキャッシュスタックには3つの層があり、それぞれ異なる特性を持つ。アプリケーションレベルのキャッシング(RedisやMemcachedなどのインメモリキャッシュ)は、高コストな計算やデータベースクエリの結果を保存する。CDNキャッシングは、静的および準静的なアセットをユーザーに近いエッジロケーションに保存する。HTTPキャッシングはCache-ControlとETagヘッダーを使用して、ブラウザやプロキシがサーバーを介さずにレスポンスをキャッシュできるようにする。

キャッシングの最も難しい部分は設定ではなく、基礎データが変更されたときのキャッシュ無効化である。業界はいくつかの信頼性の高いパターンに落ち着いている。Cache-aside(レイジーローディングとも呼ばれる)は、アプリケーションが最初にキャッシュをチェックし、ミスした場合はデータベースにフォールバックし、結果でキャッシュを設定する。Write-throughキャッシュは、すべての書き込みがキャッシュとデータベースの両方に同時に行われる。Write-behindキャッシュは、書き込みが最初にキャッシュに行われ、非同期的にデータベースにフラッシュされる。

コンピュータ科学に難しいことは2つしかない:キャッシュの無効化と名前付けである。キャッシュの無効化は、名前を変えれば消えてなくなることを期待できない分、より難しい。

ほとんどのアプリケーションでは、短いTTLを持つcache-asideが正しいデフォルトである。古いデータに対する許容度に合わせてTTLを設定する——ユーザープロファイルは60秒、商品リストは5分、参照データは24時間。より強い一貫性が必要な場合はwrite-throughキャッシュを使用するが、より高い書き込みレイテンシを受け入れる。最大の読み取りスループットが必要で、結果的な一貫性を許容できる場合は、cache-asideを余裕のあるTTLで使用する。

import redis.asyncio as aioredis
import json

cache = aioredis.Redis.from_url("redis://cache:6379")

CACHE_TTL = 300  # 5 minutes

async def get_user_profile(user_id: str) -> dict:
    key = f"profile:{user_id}"
    cached = await cache.get(key)
    if cached:
        return json.loads(cached)
    profile = await db.fetch_user(user_id)
    if profile:
        await cache.setex(key, CACHE_TTL, json.dumps(profile))
    return profile

警告の一言:キャッシングは問題を解決するのではなく隠蔽することがある。インデックスが欠落しているためにデータベースクエリが遅い場合、キャッシュを追加すると症状は隠れるが、根本的なクエリは依然として遅い。キャッシュがエビクトされ、遅いクエリが実行され、ユーザーは待つ。常に最初に遅いパスをプロファイルして最適化し、最適化されたバージョンの上にキャッシュを追加する。

データベースシャーディング——単一のデータベースが溺れないように作業を分割する

シャーディング(水平分割)は、単一のデータベースインスタンスが書き込みスループットやデータセットサイズを処理できない場合に使う方法である。データを複数のデータベースインスタンスに分割し、各インスタンス(シャード)がデータのサブセットを保持する。アプリケーションはシャードキー——通常はユーザーIDのハッシュ、地理的リージョン、または時間範囲——に基づいてどのシャードにクエリするかを決定する。

シャードキーは、シャーディングされたシステムにおける最も重要な単一の決定である。優れたシャードキーはデータをシャード間で均等に分散し、クエリパターンに合致する。悪いシャードキーはホットシャード——トラフィックの大部分を処理する少数のシャードと、残りのアイドル状態のシャード——を生み出す。例えば、作成タイムスタンプによるシャーディングは合理的に聞こえるが、今日のシャードがすべての書き込みを処理する一方で、昨年のシャードは何も処理しないことに気づく。

コンシステントハッシングは、単純なシャーディングを悩ませるリバランシング問題を解決する。単純なモジュロベースのシャーディング(shard = hash(key) % N)では、新しいシャードを追加するにはほぼすべてのデータを再シャッフルする必要がある。コンシステントハッシングはキーとシャードの両方をハッシュリング上にマッピングする。シャードを追加すると、新しいシャードのすぐ近傍にあるキーのみを移動する必要がある。これによりスケールアップとスケールダウンがはるかに容易になる。

  • ハッシュベースシャーディング——シャードキーをハッシュ化して分散、シンプルで均等だが再シャーディングは高コスト
  • レンジベースシャーディング——値の範囲で分割(例:ID 1〜10000のユーザーはシャードA、10001〜20000はシャードB)、レンジクエリに効率的だがホットスポットが発生しやすい
  • ディレクトリベースシャーディング——キーをシャードにマッピングするルックアップテーブルを維持、柔軟だがルックアップホップとディレクトリダウン時の単一障害点が発生
  • 地理的シャーディング——ユーザーリージョンで分割、レイテンシに優れるが、ユーザーが移動したりデータをグローバルにする必要がある場合に厄介

シャーディングで受け入れるトレードオフは、シャード間クエリが高コストまたは不可能になることである。usersテーブルをuser_idでシャーディングし、ordersテーブルもuser_idでシャーディングした場合、直近30日間のすべての注文を検索するクエリはすべてのシャードにヒットしなければならない。グローバルな分析やシャード間結合が必要なアプリケーションは、多くの場合、すべてのシャードから非同期的にデータを集約するセカンダリ読み取りレプリカ(または専用の分析データベース)を使用する。

CAP定理——選べるのは2つだけ

CAP定理は、分散データストアが3つの保証のうち2つ以上を同時に提供できないと述べている:Consistency(一貫性:すべての読み取りが最新の書き込みを受け取る)、Availability(可用性:たとえ最新データでなくても、すべてのリクエストが応答を受け取る)、Partition Tolerance(分断耐性:ノード間のネットワーク障害にもかかわらずシステムが動作し続ける)。

実際には、分断耐性はオプションではない。ネットワークは失敗する。パケットはドロップされ、接続はタイムアウトし、データセンターは停電する。したがって、実際の選択はCP(一貫性+分断耐性)とAP(可用性+分断耐性)の間である。etcdやZookeeperのようなCPシステムは、ノード間で一貫性を保証できない場合、読み取りの提供を拒否する。CassandraやDynamoDBのようなAPシステムは、たとえそのノードが古いデータを持っていても、到達可能なノードから読み取りを提供する。

これは学術的な区別ではない。複数のデータセンターにまたがるシステムを設計する場合、それらの間のネットワークリンクがダウンしたときに何が起こるかを決定しなければならない。古いデータの可能性があってもリクエストを処理し続けるか(AP)、ネットワークが回復するまでサービスを停止するか(CP)。答えはアプリケーションによって異なる。コンテンツデリバリーネットワークはAPであるべきだ——古いコンテンツはコンテンツがないよりはましだ。決済処理システムはCPであるべきだ——分断中に2つのノードが同じ支払いを受け入れたために顧客に二重請求をすることは絶対に避けたい。

メッセージキュー——同期的な苦痛を非同期の優雅さに変える

メッセージキューは、分散システムにおける非同期処理のバックボーンである。1つのサービス(プロデューサー)が、コンシューマーが処理するのを待たずにキューにメッセージを送信できる。コンシューマーは準備ができたときにメッセージを受け取り、処理し、完了を確認する。これによりプロデューサーとコンシューマーは時間と空間の両方で疎結合になる——同じ速度で実行する必要も、同じ時間に実行する必要もない。

自明でないバックエンドシステムはすべて、どこかでメッセージキューを使うべきである。典型的な例はメール送信である。ユーザーがプラットフォームに登録したとき、HTTPレスポンスがメール配信サービスのテンプレートレンダリング、SendGridへの接続、メッセージ配信を待つことを望まない。代わりに、APIはsend_emailイベントをキューにプッシュし、すぐに201 Createdレスポンスを返す。別のワーカーがイベントを受け取り、メールを送信し、タスクを完了としてマークする。

2つの主要なメッセージキュー・モデルは、パブリッシュ・サブスクライブ(pub/sub)とワークキューである。pub/subでは、各メッセージはすべてのサブスクライバーにブロードキャストされる。これは、複数のサービスが同じイベントに反応する必要があるイベント駆動型アーキテクチャに有用である——新しいユーザー登録は、ウェルカムメール、CRM更新、分析イベントを同時にトリガーするかもしれない。ワークキューでは、各メッセージは正確に1つのコンシューマーに配信される。これはワーカーのプール全体に作業を分散するのに有用である——各画像アップロードは正確に1つのサムネイルジェネレーターに送られる。

# docker-compose.yml — minimal RabbitMQ setup for local development
version: "3.8"
services:
  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "5672:5672"   # AMQP port for producers/consumers
      - "15672:15672" # Management UI
    environment:
      RABBITMQ_DEFAULT_USER: app
      RABBITMQ_DEFAULT_PASS: dev-only-password
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq

volumes:
  rabbitmq_data:

メッセージキューの厄介な部分は、障害を優雅に処理することである。メッセージの処理中にコンシューマーがクラッシュしたらどうなるか?RabbitMQとAmazon SQSは配信確認(delivery acknowledgment)でこれを処理する——コンシューマーはメッセージが正常に処理されたことを明示的に確認する必要がある。確認なしにコンシューマーが切断すると、メッセージは再キューイングされ、別のコンシューマーに配信される。この最低1回配信の保証は、コンシューマーが冪等でなければならないことを意味する:同じメッセージを2回処理しても、1回処理したのと同じ結果を生成しなければならない。

デッドレターキューも不可欠なパターンである。メッセージが数回のリトライ後も処理できない場合(ダウンストリームサービスがダウン、データが不正、ビジネスルールが変更)、メッセージは永久にリトライされる代わりにデッドレターキューに移動される。オペレーターがデッドレターキューを監視し、根本原因を調査し、メッセージを修正して再キューイングするか、無視しても安全であることを確認して破棄する。

CDNとレート制限——最前線の防御

コンテンツデリバリーネットワークとレート制限は異なる目的を果たすが、共通の特性を持つ:ユーザーとサーバーの間の第一防衛線であることだ。CDNは静的アセットとキャッシュされたレスポンスをユーザーの近くに保ち、レイテンシを削減し、オリジンサーバーからのトラフィックをオフロードする。レート制限は、単一のユーザーやクライアントがリクエストでシステムを圧倒することを防ぐ。

CDNはコンテンツをグローバルなエッジサーバーネットワーク全体に分散することで機能する。東京のユーザーがアセットをリクエストすると、CDNはバージニアのオリジンサーバーまでリクエストをルーティングするのではなく、最寄りのエッジロケーションから提供する。これにより、静的アセットのレイテンシが200ミリ秒から10ミリ秒に短縮される。最新のCDNはさらに進んで、APIレスポンスのキャッシュ、TLS接続の終端、エッジでのサーバーレス関数の実行が可能である。

レート制限は複数のレベルでシステムを保護する。グローバルレート制限はシステム全体が1秒間に処理できる総リクエスト数を制限し、トラフィックスパイクやDDoS攻撃から保護する。ユーザーごとのレート制限は、1つの悪意のあるテナントが他のユーザーのリソースを枯渇させるのを防ぐ。エンドポイントレベルのレート制限はルートごとに異なる制限を適用する——ログインエンドポイントは1分間に5リクエストを許可し、読み取り専用の検索エンドポイントは1分間に100リクエストを許可する。

スライディングウィンドウアルゴリズムは、正確かつ効率的であるため、レート制限の業界標準である。固定間隔でカウンターをリセットする代わりに(境界でバーストを許容する)、スライディングウィンドウはローリング時間枠にわたってリクエストを考慮する。Redisはこれを実装するための自然な選択肢である——タイムスタンプをスコアとするソート済みセットを使用し、ウィンドウ外のエントリをトリミングし、残ったエントリをカウントする。メモリコストは最小限(リクエストあたり数バイト)で、時間計算量は対数である。

// Redis-backed sliding window rate limiter (TypeScript)
import { createClient } from "redis";

const redis = createClient({ url: "redis://ratelimit:6379" });

async function checkRateLimit(
  key: string,
  limit: number,
  windowMs: number
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Date.now();
  const windowStart = now - windowMs;

  const multi = redis.multi();
  multi.zRemRangeByScore(key, 0, windowStart);
  multi.zAdd(key, { score: now, value: `${now}` });
  multi.zCard(key);
  multi.expire(key, Math.ceil(windowMs / 1000));

  const [, , count] = await multi.exec() as [any, any, number];
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
  };
}

CDNとレート制限の両方に共通する重要な運用原則がある:フェイルオープンかフェイルクローズか?CDNエッジがオリジンに到達できない場合、古いキャッシュレスポンスを提供すべきか(フェイルオープン)、エラーを返すべきか(フェイルクローズ)?レート制限のRedisクラスターがダウンした場合、すべてのリクエストを通すべきか(フェイルオープン——過負荷のリスク)、すべてのリクエストを拒否すべきか(フェイルクローズ——確実なダウンタイム)?

普遍的な答えはないが、ほとんどのシステムの適切なデフォルトは:読み取りはフェイルオープン、書き込みはフェイルクローズである。古い商品一覧ページは許容できる。失われた注文は許容できない。この決定をランブックに明示的に文書化し、オンコールエンジニアがインフラが軋むときにどのような動作を期待すべきかを知っておけるようにする。

すべてをまとめる——トレードオフで考える

システム設計はパターンを暗記することではない。トレードオフを理解し、どのパターンが自分の制約に適合するかを認識することである。すべての決定は、一貫性と可用性の間、読み取りスループットと書き込みレイテンシの間、運用の複雑さと生のパフォーマンスの間のトレードオフを伴う。最良のエンジニアは最も多くのパターンを知っている人ではなく、問題を見てどの制約が固定でどれが交渉可能かを特定できる人である。

新しいシステム設計問題に直面したときのクイックデシジョンフレームワークを紹介する。まず非機能要件をリストアップする:予想トラフィック量、レイテンシ目標、一貫性要件、インフラコストの予算、チームの技術習熟度。次に、この記事のパターンを順に検討し、それぞれが要件に近づけるか遠ざけるかを問う。

レイテンシが主要な関心事なら、キャッシングとCDNから始める——最小の複雑さで最大の改善をもたらす。可用性が重要なら、ヘルスチェック付きのロードバランシングを使用し、サービスをステートレスに設計し、ビジネスが許すならAPをCPより優先する。書き込みが多いワークロードを扱っているなら、シャーディングとメッセージキューを早期に評価する——数百万行のデータがある前の方がはるかに導入しやすい。サードパーティの開発者が使用する公開APIを扱っているなら、初日からレート制限を実装する。後から追加するにはAPIのバージョン管理や既存クライアントの破壊が必要になる。

システム設計における最も重要なスキルは、何が必要でないかを知ることである。ほとんどのアプリケーションはシャーディングを必要としない。ほとんどのアプリケーションはメッセージキューを必要としない。時期尚早な分散化はシステム設計におけるすべての悪の根源である——すべての分散システムは、単一サーバーシステムには存在しない障害モードを導入する。パターンが面接で印象的に聞こえるからではなく、メトリクスが指示したときにのみ複雑さを追加する。

シンプルに始める。すべてを測定する。一度に1つのパターンを追加する。次に進む前に改善を確認する。生き残るシステムは、最も洗練されたアーキテクチャを持つものではなく、理解しやすく、運用しやすく、次のボトルネックが現れたときに変更しやすいものである。