1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ボトルネックは潰しても次が来る — サーバー・DB・キャッシュ3段階のスケールアウト戦略

1
Posted at

はじめに

アプリを作って公開した。ユーザーが増えてきた。嬉しい。でも、ある日レスポンスが遅くなり始める——そんな話は、アプリの成長期によく聞くパターンです。

「サーバーを増やせばいいんでしょ?」と思って増やしてみると、今度はデータベースが遅くなる。DBをどうにかしたと思ったら、今度は読み取りが追いつかない。この「ボトルネックが次々と移動する」という現象は、スケールアウトに取り組んだことがある人なら誰もが通る道ではないでしょうか。

  • 「ロードバランサーって何を選べばいいの?」
  • 「DBが遅いけど、レプリカを増やせばいい?テーブルを分割すればいい?」
  • 「キャッシュを入れれば速くなる、は本当?落ちたらどうなる?」

この記事では、この現象に正面から向き合い、各段階でどんな設計判断を迫られるのかを追いかけていきます。

TL;DR

各段階の対処を一枚にまとめると以下の通りです。

ボトルネックの移動

[サーバーが詰まる]
  → ロードバランサーで水平展開(L4: 高速 / L7: 高機能)
     │
     ▼ サーバーは増えた。今度は...
[DBが詰まる]
  → レプリケーション(読み取りスケール)
  → パーティショニング(読み書きスケール)
  → NoSQL / NewSQL も選択肢に
     │
     ▼ DBも分散した。今度は...
[読み取り負荷が集中]
  → サーバーサイドキャッシュで吸収
    ローカル(簡単・低レイテンシ)
    外部(共有・スケーラブル)
    ※「キャッシュなしでも動く」設計が大前提

対象読者

  • Webアプリケーションを動かした経験はあるが、スケーラビリティを意識した設計はしたことがない方
  • ロードバランサーやキャッシュの名前は知っているが、なぜ必要か・どう選ぶかを説明できない方
  • DBが遅くなったときに「インデックスを貼る」以外の選択肢を知りたい方

Section 1: サーバーの水平展開 — ロードバランサーの設計と選択

なぜサーバーを「増やすだけ」ではうまくいかないのか

アプリサーバーが1台しかないとき、そのサーバーがダウンした瞬間にアプリ全体が止まります。これが「単一障害点」と呼ばれる状態です。

では、サーバーを2台にすればいいのかというと、それだけでは解決しません。クライアント(ブラウザやモバイルアプリ)は、リクエストをどちらのサーバーに送ればいいか分からないからです。各サーバーのIPアドレスをクライアントに教えるわけにもいきません。サーバーが増減するたびにクライアント側を変更するのは現実的ではないですよね。

ここで登場するのがロードバランサーです。ロードバランサーは、クライアントからのリクエストを受け取り、背後にある複数のサーバーに振り分ける仕組みです。クライアントはロードバランサーのアドレスだけ知っていればよく、背後にサーバーが何台あるかは意識する必要がありません。

           リクエスト
クライアント ──────→ [ロードバランサー]   ──→ サーバーA
                                      ├──→ サーバーB
                                      └──→ サーバーC

  クライアントはLBのアドレスだけ知っていればOK
  サーバーの台数は自由に増減できる

ここで一つ重要な前提があります。サーバーを自由に増やせるのは、アプリサーバーがステートレス(状態を持たない)だからです。ユーザーのログイン情報やアップロードしたファイルなどの「状態」は、すべてDBやファイルストアといった専門のサービスに押し出されています。だからこそ、どのサーバーでもリクエストを処理できるわけです。

アプリはできるだけステートレスに保ち、状態管理は専門のサービスに任せるのが定石のようです。これがスケールアウトの出発点になります。

ロードバランサーがもたらす2つの恩恵 — スケーラビリティと可用性

ロードバランサーを導入すると、大きく2つの恩恵が得られます。

1つ目はスケーラビリティです。1台のサーバーにある程度の処理能力があるなら、理論上、2台あればその2倍の処理能力が得られます。サーバーを増やすほど処理能力が線形に伸びる、という考え方です。

2つ目は可用性の向上です。可用性とは「アプリがリクエストを処理できる時間の割合」のことです。複数のサーバーが動いていれば、1台が落ちても残りのサーバーがリクエストを処理できます。全サーバーが同時にダウンしない限り、アプリは動き続けます。

どのくらい可用性が上がるのか、簡単な計算で見てみます。

可用性の計算例
可用性99%のサーバーが2台ある場合、アプリ全体が「利用不可」になるのは両方が同時にダウンしたときだけです。

$1 - (0.01 \times 0.01) = 0.9999$

理論上の可用性は99.99%になります。直感的には、独立したサーバーを増やすほど「9の数」が増えていくイメージです(厳密には対数的な関係ですが、大まかな感覚として)。

ただし、 これはあくまで理論値 です。実際には故障サーバーの除外に時間がかかったり、1台がダウンした後に残りのサーバーに負荷が集中して性能が落ちたりすることがあります。

リクエストの振り分けアルゴリズム

ロードバランサーがリクエストをどのサーバーに送るか決める方法には、いくつかの選択肢があります。

  • ラウンドロビン: 順番に1台ずつ振り分ける。最もシンプル
  • コンシステントハッシュ: リクエストの特徴(接続元IPなど)をハッシュ値に変換して、特定のサーバーに割り当てる

「負荷が低いサーバーに優先的に振り分ければいいのでは?」と思うかもしれません。自分も最初はそう思いました。しかし、これは見た目以上に難しい問題です。

ロードバランサーが各サーバーの負荷(CPU使用率など)を定期的にチェックする方法を考えてみます。サーバーに毎回問い合わせるのはコストが高いので、結果を一定期間キャッシュすることになります。すると、こんな問題が起きます。

新サーバーがプールに参加(負荷 0%)
     ↓
LBが「負荷 0% だ!」とリクエストを集中させる
     ↓
次の負荷チェック時、サーバーは過負荷に
     ↓
LBがリクエスト送信を停止
     ↓
サーバーが暇に → また「負荷 0%」を報告
     ↓
再びリクエストが殺到... (この振動が繰り返される)

実は、古くなった負荷情報に基づいて振り分けるよりも、負荷を一切考えずにランダムに振り分けた方が、より均等な負荷分散を実現できることが分かっています。

では、負荷メトリクスは全く使えないのかというと、そうでもありません。Power of Two Choices(2つの候補からマシな方を選ぶ)という手法があります。プールからランダムに2台を選び、その2台のうち負荷が低い方にリクエストを送る、というシンプルなアイデアです。ランダム性と負荷メトリクスを組み合わせることで、先ほどの振動問題を避けつつ、実践的に良い負荷分散を実現できます。

サーバーの発見と健康管理 — サービスディスカバリとヘルスチェック

ロードバランサーがリクエストを振り分けるには、2つの情報が必要です。「どのサーバーが存在するか」と「どのサーバーが健康か」です。

サービスディスカバリ — サーバーを見つける仕組み

サービスディスカバリとは、ロードバランサーがリクエストを送信できるサーバーのプール(一覧)を発見するための仕組みです。

最もシンプルな方法は、すべてのサーバーのIPアドレスを静的な設定ファイルに列挙することです。ただ、サーバーの追加・削除のたびにファイルを更新するのは正直辛い作業です。

より柔軟な方法として、フォールトトレラント(障害耐性のある)な調整サービスを使う方法があります。新しいサーバーがオンラインになると、TTL(Time To Live: 有効期限)を設定して自分自身をサービスに登録します。サーバーが停止したり、登録の更新を忘れてTTLが切れたりすると、自動的にプールから除外されます。

この「サーバーを動的に追加・削除できる」仕組みは、クラウドのオートスケーリング(負荷に応じてサーバーを自動的に起動・停止する機能)の基盤にもなっています。

ヘルスチェック — サーバーの健康状態を確認する

ヘルスチェックは、サーバーがリクエストを正常に処理できるかを確認し、問題があるサーバーをプールから除外する仕組みです。大きく2種類あります。

受動的(パッシブ)ヘルスチェックは、実際のリクエスト転送時に異常を検知する方法です。接続失敗・タイムアウト・503エラーなどが続いたら、そのサーバーをプールから外します。特別な準備が不要なのが利点です。

能動的(アクティブ)ヘルスチェックは、サーバー側に専用のヘルスエンドポイント(例: /health)を用意し、ロードバランサーが定期的にアクセスして状態を確認する方法です。正常なら200 OKを返し、問題があれば5xx系のエラーを返します。

このエンドポイントは、単に200 OKを返すだけの実装でも十分機能します。サーバーが劣化していれば、ほとんどのリクエストはタイムアウトするからです。もちろん、CPU使用率やメモリ残量を閾値と比較して、より精密に判定する実装も可能です。

ヘルスチェックの落とし穴
閾値の設定ミスやヘルスチェックのバグによって、すべてのサーバーが「不健康」と判定されてしまう可能性があります。単純なロードバランサーだとプールが空になり、アプリが完全にダウンします。

実践的なロードバランサーでは、大多数のサーバーが不健康と判定された場合、「ヘルスチェック自体が信頼できない」と判断して、ヘルスチェックを無視し全サーバーにリクエストを送り続けます。いわば「パニック回避モード」です。

ヘルスチェックの応用 — ローリングアップデートと自己修復

ヘルスチェックの仕組みは、サーバーの健康管理だけでなく、運用にも活用できます。

ローリングアップデートでは、サーバーを1台ずつ順に更新します。更新対象のサーバーが「利用不可」をヘルスエンドポイントで報告し、ロードバランサーがリクエスト送信を停止。処理中のリクエストを完了させてから(ドレイン)、新しいバージョンで再起動します。これを全サーバーに対して順番に行うことで、ダウンタイムなしのアップデートが実現できます。

もう一つ面白い応用がウォッチドッグパターンです。

まず前提として、メモリリークについて説明します。サーバーが処理を行うとき、データを一時的にメモリ上に置いて作業します。通常、処理が終わればそのメモリは解放されますが、プログラムのバグで解放されずに残り続けることがあります。これがメモリリークです。

【メモリリークの進行】

起動直後:   [使用中: 20%  | 空き: 80%         ] → 快適
 ↓ 数時間後
稼働中:     [使用中: 60%  | 空き: 40%         ] → やや重い
 ↓ 数日後
限界付近:   [使用中: 95%  | 空き: 5%          ] → 非常に遅い

使えるメモリが減るほど処理速度が落ちていきます。完全に壊れてはいないが性能が劣化しているこの状態をグレイ障害と呼びます。

ウォッチドッグは、この問題に対する自動的な応急処置です。

  1. サーバー内でバックグラウンドスレッドがメモリ残量を常に監視する
  2. 空きメモリが危険な水準まで減ったら、サーバーを意図的に再起動させる
  3. 再起動でメモリがリセットされ、性能が元に戻る
  4. ロードバランサーが再起動中のサーバーにはリクエストを送らないため、ユーザーには影響しない

根本原因(メモリリーク)の修正には時間がかかることが多いですが、ウォッチドッグで自動再起動させることで、運用担当者は調査の時間を稼ぐことができます。

L4ロードバランサー — TCPレベルの高速な振り分け

ここからは、ロードバランサーの具体的な実装方式の話に入ります。「全リクエストがロードバランサーを通る」以上、その仕組みを知ることはパフォーマンスと可用性に直結します。

DNSロードバランシングとその限界

ロードバランシングを実装する最もシンプルな方法は、DNSを使うことです。複数サーバーのIPアドレスをDNSレコードに登録しておき、クライアントがDNSを引いたときにランダムに1つを選ぶ方式です。

しかし、この方式には大きな問題があります。サーバーがダウンしてもDNSサーバーはそれを検知できず、ダウンしたサーバーのIPアドレスを返し続けてしまいます。DNSレコードを書き換えても、DNSの応答はクライアント側にキャッシュされるため、変更が行き渡るまで時間がかかります。

そのため、DNSロードバランシングが実用上使われるのは、リージョン(地域)をまたいだトラフィック分散(グローバルDNSロードバランシング)程度に限られます。

L4ロードバランサーの仕組み

より本格的なロードバランサーとして、ネットワークのTCPレベル(トランスポート層、いわゆるレイヤー4)で動作するL4ロードバランサーがあります。

                DNS
                 │ "LBの仮想IPは 203.0.113.10"
                 ▼
クライアント ──→ [エッジルーター] ──→ [ L4 ロードバランサー ]
                                     VIP: 203.0.113.10
                                        │
                              ┌─────────┼─────────┐
                              ▼         ▼         ▼
                          サーバーA  サーバーB  サーバーC

L4ロードバランサーは仮想IP(VIP) と呼ばれるIPアドレスを公開します。クライアントにはこのVIPしか見えず、背後にあるサーバーの存在は分かりません。

クライアントがVIPに対してTCP接続を開始すると、ロードバランサーがプールからサーバーを1台選び、以降のパケットをクライアントとサーバーの間で転送します。この転送ではNAT(Network Address Translation)という仕組みを使い、パケットの送信元・送信先のアドレスを書き換えます。

接続のサーバーへの割り当てには、コンシステントハッシュが使われることが多いです。これにより、サーバーの追加や削除があっても、既存の接続への影響を最小限に抑えられます。

DSR(ダイレクトサーバーリターン)とは
通常、サーバーからクライアントへの応答データは、リクエストデータよりもはるかに大きくなります(HTMLページやAPI応答など)。DSRは、サーバーからの応答をロードバランサーを経由せずにクライアントへ直接返す仕組みです。これにより、ロードバランサーの負荷を大幅に軽減できます。

L4ロードバランサー自体もスケールアウトが可能です。複数のロードバランサーインスタンスが同じVIPを共有し、エッジルーターが等コストマルチパスルーティング(ECMP)で振り分けます。

クラウド環境では、AWS Network Load BalancerやAzure Load Balancerなど、マネージドサービスとして提供されています。

ただし、L4ロードバランサーには弱点もあります。TCPレベルでバイト列を転送しているだけなので、HTTPの中身は理解できません。TLS接続の終端やHTTPヘッダーの検査といった機能は提供できないのです。

L7ロードバランサー — HTTPを理解するインテリジェントな振り分け

L4の弱点を補うのが、HTTPレベル(アプリケーション層、いわゆるレイヤー7)で動作するL7ロードバランサーです。L7ロードバランサーはHTTPリバースプロキシとして動作し、リクエストの中身を見て判断できます。

L4とL7の大きな違いは「接続の構造」にあります。L4ではクライアントとサーバーの間にロードバランサーが透過的に入りますが、L7では2つの独立したTCP接続が存在します。クライアントとL7ロードバランサーの間の接続と、L7ロードバランサーとサーバーの間の接続です。

┌──────────────────────────────────────────────────────┐
│ L4 ロードバランサーの場合                                │
│                                                      │
│  クライアント ←──── 1つのTCP接続 ────→ サーバー           │
│                    (LBはパケットを転送)                │
├──────────────────────────────────────────────────────┤
│ L7 ロードバランサーの場合                                │
│                                                      │
│  クライアント ←─ TCP接続1 ─→ LB ←─ TCP接続2 ─→ サーバー   │
│                     (LBがHTTPの中身を理解)            │
└──────────────────────────────────────────────────────┘

この構造のおかげで、L7ロードバランサーは以下のような高度な機能を提供できます。

  • HTTPヘッダーベースのレート制限: 特定のヘッダーやURLパターンに基づいてリクエスト数を制限
  • TLS接続の終端: HTTPSの暗号化/復号をロードバランサーで処理し、バックエンドサーバーとの通信はHTTPで行う
  • セッション維持(スティッキーセッション): クッキーでセッションを識別し、同じセッションのリクエストを常に同じサーバーに送る

スティッキーセッションの注意点
セッションを特定のサーバーに固定すると、そのサーバーはセッションデータをメモリにキャッシュできるため効率的です。しかし、一部のセッションが他よりもはるかに重い処理を必要とする場合、特定のサーバーに負荷が集中する「ホットスポット」が発生する可能性があります。

また、HTTP/2では複数のリクエストが1つのTCP接続上で同時に流れます。L7ロードバランサーなら、この個々のリクエストを別々のサーバーに振り分けることができますが、L4ではTCP接続単位でしか振り分けられません。

L4とL7の使い分け

実務では、L4とL7を組み合わせて使うパターンが多いです。

L4ロードバランサー L7ロードバランサー
動作レイヤー TCP(トランスポート層) HTTP(アプリケーション層)
スループット 高い L4より低い
機能 シンプル(パケット転送) 高機能(TLS終端、レート制限など)
DDoS防御 向いている(SYNフラッドなど) L4ほどではない
HTTP理解 できない できる

典型的な構成では、L4をフロントに置いてDDoS対策と高スループットの分散を担当させ、その背後にL7を配置してHTTPレベルの高度な振り分けを行います。

サイドカーパターン — サーバー間の内部通信を分散管理する

ここまで見てきたL4/L7ロードバランサーは、主にエンドユーザー(ブラウザ)からのリクエストを受け付ける「外部向けの入口」です。しかし、システムが複数のサービスで構成されている場合、サービス同士の内部通信も大量に発生します。この内部通信にも振り分けや認証が必要ですが、内部通信ごとに専用のロードバランサーを置くと管理対象が増え、そのLB自体が単一障害点になるリスクもあります。

サイドカーパターンは、この問題を解決します。アプリ本体の隣にもう1つ小さなプロセス(サイドカープロキシ)を追加し、振り分けや認証といった通信まわりの仕事をそちらに任せます。アプリ本体には手を加えず、通信機能だけを外付けで追加できるのがポイントです。

【外部 + 内部の全体構成】

エンドユーザー(ブラウザ)
      │
      ▼
[外部向けL4/L7ロードバランサー]  ← 外部からの入口はそのまま
      │
      ▼
┌─ 注文サービス ────┐      ┌─ 在庫サービス ────┐
│ [注文アプリ]      │      │ [在庫アプリ]      │
│   ↓             │      │   ↓              │
│ [サイドカー]      │ ──→  │ [サイドカー]      │
└─────────────────┘      └─────────────────┘
  内部通信はサイドカーが振り分け → 内部用の専用LBが不要

この方式はサービスメッシュとも呼ばれ、マイクロサービスアーキテクチャで広く採用されています。代表的なサイドカープロキシとしてはEnvoyが事実上の標準で、Istioなど主要なサービスメッシュで採用されています。一方で、すべてのサイドカーの設定を統一管理するためのコントロールプレーンが必要になり、システム全体の複雑さは増します。

サービスメッシュは進化の途上にある
サイドカーパターンは広く普及しましたが、各マシンにプロキシプロセスを配置するオーバーヘッドも課題になっています。最近では、サイドカーを使わずにノードレベルのプロキシで同等の機能を提供するアーキテクチャ(Istio Ambient Modeなど)も登場・安定化しており、この分野はまだ活発に進化しています。

Section 1 まとめ — 次のボトルネックはどこか?

ここまでで、ロードバランサーを使ってサーバーを水平展開する方法を見てきました。L4で高速にパケットを分散させ、L7でHTTPレベルの高度な振り分けを行い、ヘルスチェックで障害に対応する。これでサーバー側のスケールアウトは実現できました。

しかし、サーバーを増やすと、各サーバーからDBへのリクエストも当然増えます。次のボトルネックはDBに移動します。

Section 2: DBの水平展開 — レプリケーション・パーティショニング・NoSQL

アプリサーバーはステートレスだったので、同じものをコピーして並べるだけで済みました。一方、DBは「状態」を持つサービスです。状態を複数のマシンに複製・分散させるには何らかの調整が必要で、それが複雑さの原因になります。

ここからは、DBをスケールさせる3つの段階を見ていきます。

┌────────────────────────────────────────────────────────┐
│              DBスケールの3段階                           │
│                                                        │
│  レプリケーション ──→ パーティショニング ──→ NoSQL/NewSQL    │
│  (読み取りを分散)  (読み書きを分散)    (最初から          │
│                                        スケール前提)    │
└────────────────────────────────────────────────────────┘

レプリケーション — まず読み取りをスケールさせる

多くのWebアプリでは、読み取り(SELECT)が書き込み(INSERT/UPDATE/DELETE)よりも圧倒的に多いです。SNSで言えば、タイムラインを見る回数と投稿する回数の差を考えるとイメージしやすいと思います。なので、まずは読み取りの負荷を分散するところから始めるのが現実的です。

レプリケーションは、DBのコピー(レプリカ)を複数台作って、読み取りリクエストを分散させる手法です。

最も一般的な構成がリーダー・フォロワー構成です。

仕組みはシンプルです。

  1. 書き込みはリーダー1台だけが受け付ける
  2. リーダーは変更が入るたびに、その内容をライトアヘッドログ(変更操作を順番に書き出したログ)に記録する
  3. フォロワーはリーダーとの接続を維持し、変更が発生するたびにログを受け取ってローカルで適用する
  4. 読み取りはフォロワーが受け付ける。ロードバランサーで複数のフォロワーに分散する

ログの各レコードには連番が付いているので、フォロワーがネットワーク切断などで一時的に遅れても、「最後に受け取った番号」からやり直せます。

レプリケーションによる可用性の向上

レプリケーションはスケーラビリティだけでなく、障害への耐性も上がります。

  • フォロワーが落ちた場合: ロードバランサーがそのフォロワーへの振り分けを止めるだけ。残りのフォロワーで処理を続けられる
  • リーダーが落ちた場合: フォロワーの1つを新しいリーダーに切り替える(フェイルオーバー
  • 重い分析クエリ: 特定のフォロワーに分析用クエリを集中させれば、他のフォロワーの読み取り性能に影響しない

同期と非同期のトレードオフ

リーダーからフォロワーへの変更の反映には、「フォロワーの反映完了を待つかどうか」という設計判断があります。

完全非同期 完全同期
仕組み リーダーはフォロワーの完了を待たずにクライアントに応答 リーダーは全フォロワーの完了を待ってから応答
レスポンス速度 速い 遅い(最も遅いフォロワーに引きずられる)
データ安全性 リーダーが落ちたとき、まだ反映されていない変更が失われる可能性 データ損失なし
可用性 高い 1台でも通信不能ならDB全体が止まる

実際には、どちらか一方ではなく組み合わせが使われます。例えばPostgreSQLでは、一部のフォロワーだけを同期にする設定ができます。同期フォロワーは常にリーダーと同じデータを持っているので、リーダーが落ちたときにデータ損失なく切り替えられます。

フェイルオーバーの流れ:

  1. リーダーの故障を検知する
  2. 同期フォロワーを新しいリーダーに切り替え、他のフォロワーが新しいリーダーを向くように設定を変更する
  3. クライアントからのリクエストが新しいリーダーに届くようにする

AWS RDSやAzure SQL Databaseなどのマネージドサービスでは、このフェイルオーバーが自動で行われます。

レプリケーションの限界

レプリケーションは強力ですが、限界もあります。

  • 読み取りはスケールするが、書き込みはスケールしない。すべての書き込みがリーダー1台を経由するので、書き込み量が増えるとリーダーがボトルネックになる
  • DB全体が1台のマシンに収まる必要がある。テーブルごとに別マシンに分ける方法で多少は延命できるが、根本的な解決にはならない

書き込みもスケールさせたい、あるいはデータ量が1台に収まらなくなった場合は、データ自体を分割するパーティショニングが必要になります。

パーティショニング — 読み書き両方をスケールさせる

パーティショニングとは、データを複数のノード(マシン)に分割して、読み書き両方のリクエストを分散させる手法です。

考え方自体はシンプルですが、従来のリレーショナルデータベース(RDB)で複数ノードへのパーティショニング(シャーディングとも呼ばれます)を実装するのは容易ではありません。単一ノード内でのテーブル分割はPostgreSQLなどでサポートされていますが、複数ノードへのデータ分散は標準機能としてサポートされていないことが多いのです。

アプリケーション層で自前のシャーディングを実装しようとすると、膨大な複雑さが待っています。

  • データの分割方法を決め、ホットスポット(負荷の偏り)が生じたらリバランスする
  • パーティションをまたぐクエリは、サブクエリに分割して実行し、結果を統合する
  • アトミックな(中途半端な状態にならない)トランザクションを実現するために、分散トランザクションプロトコルを実装する
  • パーティショニングとレプリケーションを組み合わせて、各パーティションの可用性も確保する

正直なところ、これらすべてを自前で実装するのは気が遠くなります。

なぜRDBは複数ノードのパーティショニングが難しいのか
根本的な理由は、従来のRDBが「単一の強力なマシンにすべてが収まる」という前提で設計されたことにあります。ACIDトランザクション(データの整合性を保証する仕組み)やJOIN(複数テーブルのデータを結合するクエリ)は、単一マシン内では効率的に動きますが、複数マシンにまたがると一気に難しくなります。

また、RDBが設計された時代はディスク容量が高価だったため、データの「正規化」(重複を排除してストレージを節約すること)が優先されました。正規化されたデータはJOINで結合して使うことが前提です。しかし現在はストレージが安価になり、むしろCPU時間の方が貴重です。設計の前提条件が変わったのです。

こうした背景から、「最初からスケーラビリティを組み込んだデータストアを使えばいいのでは?」という発想が生まれました。

NoSQL — スケーラビリティを最初から組み込んだデータストア

2000年代初頭、大手テック企業は自社の膨大なデータを扱うために、スケーラビリティを最初から念頭に置いた独自のデータストアを構築し始めました。GoogleのBigtableやAmazonのDynamoといった論文は業界に大きな影響を与え、HBaseやCassandraなどのオープンソース実装も生まれました。

これらの初期のデータストアはSQLをサポートしていなかったため、NoSQLと呼ばれるようになりました。ただし現在では、NoSQLストアもSQLに近いクエリ言語をサポートするものが増えているため、この名称は少し誤解を招きやすいかもしれません。

NoSQLとRDBの違い

NoSQLがRDBと異なる点を整理します。

特徴 RDB NoSQL
一貫性モデル 強い一貫性(厳密なシリアライザビリティ等) 結果整合性・因果一貫性など緩い一貫性
データモデル 正規化 + JOINで結合 非正規化(キーバリューやJSON等)
JOIN サポート 基本的に不要(設計上排除)
トランザクション ACID保証 パーティション内に限定されることが多い
パーティショニング 標準では複数ノード分散が難しい ネイティブサポート

結果整合性とは、「書き込みが行われた直後は、すべてのレプリカで同じデータが見えるとは限らないが、時間が経てば一致する」という考え方です。強い一貫性よりも高い可用性を実現できるため、NoSQLの多くが採用しています。

DynamoDBを例にしたデータモデリング

NoSQLのデータモデリングを、Amazon DynamoDBを例に具体的に見てみます。

DynamoDBのテーブルは「項目(items)」の集まりです。各項目はプライマリキーで一意に識別されます。プライマリキーは、パーティションキー単独か、パーティションキー + ソートキーの組み合わせです。

  • パーティションキー: データがどのノードに配置されるかを決定する
  • ソートキー: パーティション内でのデータの並び順を定義し、範囲クエリを可能にする

例えば「特定の顧客の注文リストを日付順に取得する」というアクセスパターンが主要な場合、パーティションキーを「顧客ID」、ソートキーを「注文日」に設定します。

パーティションキー ソートキー 属性 属性
jonsnow 2021-07-13 OrderID: 1452 Status: Shipped
aryastark 2021-07-20 OrderID: 5252 Status: Placed

ここで、注文リストに「顧客のフルネーム」も含めたい場合を考えます。RDBなら「顧客テーブル」と「注文テーブル」を分けてJOINしますが、NoSQLでは同じテーブルに異なる種類のデータを混在させることで、JOINなしで1回のクエリで取得できます。

パーティションキー ソートキー 属性 属性
jonsnow 2021-07-13 OrderID: 1452 Status: Shipped
jonsnow jonsnow FullName: Jon Snow Address: ...
aryastark 2021-07-20 OrderID: 5252 Status: Placed
aryastark aryastark FullName: Arya Stark Address: ...

顧客情報と注文情報が同じパーティションキーを持つため、「jonsnow」で検索すれば、その顧客の情報と注文が一度に取得できます。RDBなら2つのテーブルへのJOINが必要な操作を、1回のクエリで完結させられるのです。

NoSQLの最重要ポイント

ここまで見てきたように、NoSQLではデータの構造が「どうクエリするか」に強く依存します。これがNoSQLの最も重要なポイントです。

「NoSQLデータストアを使うには、アクセスパターンを事前に特定し、それに応じてデータをモデリングする必要がある」

「NoSQLはスキーマレスだから柔軟」というのは、よくある誤解です。実態はむしろ逆で、アクセスパターンと密結合するため、後からアクセスパターンを変えたくなった場合の柔軟性はRDBよりも低くなります。RDBでは新しいクエリを書けばある程度対応できますが、NoSQLではテーブル設計自体を見直す必要が出てくる場合があります。

NewSQL — NoSQLのスケーラビリティとRDBのACID保証を両立する

NoSQLとRDBのトレードオフを見て、「両方のいいとこ取りはできないの?」と思った方もいるかもしれません。

実は、それを目指したデータストアが存在します。NewSQLと呼ばれるカテゴリです。

RDB NoSQL NewSQL
ネットワーク分断時 可用性を優先 一貫性を優先
スケーラビリティ 限定的 高い 高い
ACID保証 あり 限定的 あり

NewSQLの考え方は、「適切な設計を行えば、一貫性を優先しても可用性への影響はごくわずかに抑えられる」というものです。完璧な100%の可用性はどのみち不可能です(可用性はいつも「99.99%」のような数値で表されますよね)。ならば、一貫性を保ちつつスケールする方が合理的ではないか、という発想です。

CockroachDBやGoogle Spannerが代表例として知られています。

Section 2 まとめ — まだDBに毎回問い合わせますか?

DBのスケールアウトには、レプリケーション(読み取りの分散)、パーティショニング(読み書きの分散)、NoSQL/NewSQL(最初からスケール前提の設計)という段階的な選択肢があることを見てきました。

しかし、DBをスケールさせても、アクセスの大部分が少数の人気データに集中していることはよくあります。毎回DBに問い合わせるのは無駄です。キャッシュの出番です。

Section 3: サーバーサイドキャッシュ — DBの負荷を吸収する

キャッシュの基本と大前提

キャッシュとは、DB(オリジン)からのレスポンスを一時的に保存しておく高速なストレージ層のことです。同じデータへのリクエストが来たら、DBに問い合わせずにキャッシュから直接返すことで、レスポンス速度の向上とDBの負荷軽減を同時に実現します。

まず大事な前提を確認させてください。

キャッシュはあくまで「最適化」である
キャッシュなしの状態でDBが負荷に耐えられないなら、それはスケーラブルなアーキテクチャとは言えません。アクセスパターンが突然変わってキャッシュミスが増えたり、キャッシュ自体がダウンしたりしても、アプリが完全に停止してはいけません。遅くなるのは許容されますが、動き続けることが重要です。

キャッシュが効果を発揮するかどうかは、ヒット率(キャッシュから直接応答できたリクエストの割合)次第です。ヒット率は以下の要因に左右されます。

  • キャッシュ対象のオブジェクト種類が少ないほど良い
  • 同じオブジェクトに繰り返しアクセスされるほど良い
  • キャッシュのサイズが大きいほど良い

また、コールスタック(リクエストの処理経路)の上流でキャッシュするほど、下流で節約できるリソースは多くなります。

キャッシュポリシー — ミス時の取得方法と追い出し戦略

キャッシュを導入する際に決めるべきことが2つあります。「キャッシュにデータがなかったとき(ミス時)にどうするか」と「容量がいっぱいになったらどうするか」です。

ミス時の取得パターン

キャッシュミスが発生したとき、不足しているデータをオリジン(DB)から取得する方法には、2つのパターンがあります。

┌─────────────────────────────────────────────────┐
│  サイドキャッシュ                                  │
│                                                 │
│  アプリ → キャッシュ → 「ないよ」                    │
│  アプリ → DB → データ取得                          │
│  アプリ → キャッシュに書き込み                       │
│                                                 │
│  ※アプリがオリジンとキャッシュの両方を知っている        │
├─────────────────────────────────────────────────┤
│  インラインキャッシュ                              │
│                                                 │
│  アプリ → キャッシュ → 「ないからDBに聞くね」         │
│           キャッシュ → DB → データ取得             │
│           キャッシュ → アプリにデータ返却            │
│                                                 │
│  ※アプリはキャッシュだけを知っていればOK              │
└─────────────────────────────────────────────────┘

サイドキャッシュは、アプリがキャッシュとオリジンの両方にアクセスする構成です。キャッシュはキーバリューストアとして利用され、アプリが取得・更新のロジックを制御します。

インラインキャッシュは、キャッシュ自体がオリジンとの通信を担当する構成です。アプリはキャッシュだけにアクセスすればよく、オリジンの存在を意識する必要がありません。

エビクション(追い出し)ポリシー

キャッシュには容量の制限があるため、いっぱいになったら何かを追い出す必要があります。

  • LRU(Least Recently Used): 最後にアクセスされてから最も時間が経ったエントリを追い出す。最も広く使われるポリシー
  • TTL(Time To Live): 設定した有効期限を超えたエントリを期限切れとして扱う

TTLは直感的で分かりやすいですが、トレードオフがあります。TTLを長くするとヒット率は上がりますが、古いデータ(スタールデータ)を返してしまうリスクが高まります。

期限切れエントリにも「使い道」がある
期限切れのエントリは必ずしもすぐに削除する必要はありません。オリジン(DB)が一時的にダウンしている場合、エラーを返すよりも期限切れの少し古いデータを返す方がシステムの回復性(レジリエンス)は高まります。

TTLベースの期限切れは、実は「キャッシュの無効化」という非常に難しい問題への回避策でもあります。例えば、あるDBクエリの結果をキャッシュした場合、そのクエリに関係するデータが変更されるたびにキャッシュを正しく無効化する必要がありますが、関連データが数千レコードに及ぶ可能性もあり、完全な無効化の実装は極めて困難です。

ローカルキャッシュ — シンプルだが限界がある

キャッシュの最もシンプルな実装は、アプリサーバーのプロセス内に置くことです。インメモリのハッシュテーブルや組み込みのキーバリューストアを使います。

┌─── サーバーA ───┐   ┌─── サーバーB ───┐   ┌─── サーバーC ───┐
│ [アプリ]        │   │ [アプリ]        │  │ [アプリ]        │
│ [ローカル       │   │ [ローカル       │   │ [ローカル       │
│   キャッシュ]    │   │   キャッシュ]   │   │   キャッシュ]   │
└────────┬───────┘   └────────┬──────┘   └────────┬───────┘
         └──────────────┬─────────────────────────┘
                        ▼
                      [ DB ]

メリット: 実装がシンプルで、ネットワークアクセスが不要なのでレイテンシが低いです。

デメリット: 各サーバーのキャッシュが独立しているため、いくつかの問題が生じます。

  • データの重複: 同じデータが複数のキャッシュに保存される。各サーバーが1GBのキャッシュを持っていても、システム全体で保持できるユニークなデータは最大1GB分
  • 一貫性の問題: 異なるサーバーが同じデータの異なるバージョンを持つ可能性がある
  • オリジンへの負荷: サーバー数が増えるほど、DBへのリクエスト数も増える

さらに厄介なのがサンダリングハード(thundering herd)現象です。これは、サーバーの再起動や新しいサーバーの追加でキャッシュが空の状態になったとき、大量のリクエストが一斉にDBに殺到する現象です。今まで人気がなかったデータが突然注目を集めた場合にも発生します。

緩和策としてリクエストのコアレシング(合体)があります。同じオブジェクトに対する未完了のリクエストを、サーバーあたり最大1つに制限する仕組みです。10件の同時リクエストがあっても、DBに行くのは1件だけで、結果が返ってきたら10件すべてに共有します。

外部キャッシュ — 共有することで得られるもの、失うもの

ローカルキャッシュの「データの重複」「一貫性の問題」「スケールしない」という欠点を解消するのが、外部キャッシュです。キャッシュを独立したサービスとして切り出し、すべてのアプリサーバーで共有します。

┌─── サーバーA ───┐   ┌─── サーバーB ───┐   ┌─── サーバーC ───┐
│ [アプリ]        │   │ [アプリ]       │   │ [アプリ]        │
└────────┬───────┘   └────────┬──────┘   └────────┬───────┘
         └──────────────┬─────────────────────────┘
                        ▼
               [外部キャッシュサービス]
           (Redis / Valkey / Memcached)
                        │
                        ▼
                      [ DB ]

代表的なインメモリキャッシュサービスとしてはRedisやMemcachedがあり、AWSやAzureではマネージドサービスとしても提供されています。

RedisとValkeyについて
Redisは2024年3月にオープンソースライセンス(BSD)からRSALv2/SSPLv1に変更されました。これを受け、Linux Foundation傘下でBSDライセンスのフォーク「Valkey」が誕生し、AWS・Google・Microsoftなどが支持を表明しています。その後、2025年5月にRedis 8でOSI承認のAGPLv3が追加され、トリプルライセンス(RSALv2/SSPLv1/AGPLv3)となりました。2026年現在、Redis/Valkeyの選択は実務上の論点の一つになっています。

外部キャッシュのメリット:

  • レプリケーションやパーティショニングでスループットと容量を増強可能(例: Redisはデータを複数ノードに自動パーティショニングし、各パーティションをリーダー・フォロワーで複製できる)
  • キャッシュが共有されるため、各オブジェクトのバージョンが1つに統一され、一貫性が向上
  • DBへのリクエスト数がサーバーの台数に比例して増えることがない

外部キャッシュのデメリット:

  • ネットワーク経由のアクセスになるため、ローカルキャッシュよりレイテンシが高い
  • 運用すべきサービスが1つ増える(メンテナンスコスト)
  • スケールアウト時にキャッシュデータの再配置が必要(コンシステントハッシュ等で移動量を最小化する)

外部キャッシュがダウンしたときの危険

外部キャッシュの障害シナリオは、個人的に想像以上に怖いと感じた部分です。

「キャッシュがダウンしたら、一時的にDBに直接アクセスすればいい」と思いがちですが、DBはキャッシュがあることを前提とした負荷設計になっている可能性があります。キャッシュが吸収していた大量のリクエストが突然DBに殺到すると、DB自体もダウンしてしまうカスケード障害(連鎖的な障害)を引き起こす恐れがあります。

この問題への防御策としては、以下のアプローチがあります。

  • ローカルキャッシュを併用する: 外部キャッシュ障害時のフォールバック(代替手段)として、プロセス内のローカルキャッシュも持っておく
  • DB側でリクエストを意図的に拒否する(リクエストシェディング): リクエストが殺到したとき、すべてを処理しようとしてDB全体が落ちるよりも、一部を拒否してシステム全体の崩壊を防ぐ

改めて強調しますが、冒頭で述べた通り、システムはキャッシュなしでも存続できる設計が必要です。

まとめ — ボトルネック移動の地図

この記事では、アプリの成長に伴ってボトルネックが移動していく様子を追いかけてきました。最後に全体像を振り返ります。

サーバー1台で限界
  │
  ▼ ロードバランサーを導入
サーバーN台に水平展開(L4で高速分散 / L7で高機能な振り分け)
  │
  │ サーバーが増えるとDBへのリクエストも増える
  ▼ ボトルネックがDBに移動
DB1台で限界
  │
  ├─▶ レプリケーション → 読み取りをスケール
  │
  ├─▶ パーティショニング → 読み書き両方をスケール
  │
  ├─▶ NoSQL / NewSQL → 最初からスケール前提のDB
  │
  │ 人気データへの繰り返しアクセスがDBに無駄な負荷をかけている
  ▼ ボトルネックが読み取り負荷に移動
キャッシュでDB負荷を吸収
  │
  ├─▶ ローカルキャッシュ → シンプル・低レイテンシ
  │
  └─▶ 外部キャッシュ → 共有・スケーラブル・一貫性向上

スケールアウトは「サーバーを増やすこと」ではなく、ボトルネックが移動する順番を読んで、サーバー・DB・キャッシュを連動して設計することです。この「ボトルネック移動の地図」を頭に入れておくと、「次に何をすべきか」の判断がしやすくなると思います。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?