SYSTEM DESIGN GUIDE — PART 4 / 7
- システムの部品箱
- CHAPTER 20キャッシュと CDN
- CHAPTER 21メッセージキューとストリーム処理基盤
- CHAPTER 22全文検索とベクター検索(セマンティック検索・ANN)
- CHAPTER 23オブジェクトストレージとファイル配信
- CHAPTER 24レート制限
- CHAPTER 25分散 ID 生成
- CHAPTER 26確率的データ構造
- CHAPTER 27地理空間インデックス
システムの部品箱
キャッシュ、CDN、メッセージキュー、全文検索とベクター検索、レート制限、分散 ID、確率的データ構造、地理空間インデックス。設計図に置く「箱」の中身を一つずつ開けます。
- ① 導入と土台
- ② ネットワークと通信
- ③ データを保存する
- ④ システムの部品箱
- ⑤ 壊れないシステム
- ⑥ アーキテクチャの型
- ⑦ 面接を突破する
このページの内容
- CH 20キャッシュと CDN
- CH 21メッセージキューとストリーム処理基盤
- CH 22全文検索とベクター検索(セマンティック検索・ANN)
- CH 23オブジェクトストレージとファイル配信
- CH 24レート制限
- CH 25分散 ID 生成
- CH 26確率的データ構造
- CH 27地理空間インデックス
CHAPTER 20キャッシュと CDN
キャッシュを「どこに・どう置き・どう壊れるか」まで説明できる。
20.1キャッシュを置ける場所(全部言えるように)
ブラウザキャッシュ
↓
CDN / エッジキャッシュ ← 静的コンテンツ、地理的に近い
↓
ロードバランサ / リバースプロキシキャッシュ
↓
アプリのローカルメモリ(プロセス内) ← 最速だがノードごとに別物
↓
分散キャッシュ(Redis / Memcached) ← 共有できる。ネットワーク 1 往復
↓
データベースのバッファプール ← DB が自前で持つページキャッシュ
↓
OS のページキャッシュ
↓
ディスク
ユーザーに近いほど速く安いが、無効化が難しい。
20.2キャッシュ戦略
| 戦略 | 読み取り | 書き込み | 特徴 |
|---|---|---|---|
| Cache-Aside(Lazy Loading) | キャッシュを見る→無ければ DB→書き戻す | DB に書いてキャッシュを削除 | 最も一般的。実装が単純。初回は必ずミス |
| Read-Through | キャッシュライブラリが自動で DB を読む | — | アプリが単純になる |
| Write-Through | — | キャッシュと DB を連携して書く | 原子的な連携がなければ常に一貫とは限らない。書き込みが遅く、書いたが読まれないデータで無駄が出る |
| Write-Behind(Write-Back) | — | キャッシュに書いて、後でまとめて DB へ | 超高速だがデータ損失リスク。カウンタ集計向き |
| Refresh-Ahead | 期限切れ前に先回りで更新 | — | 予測が当たれば常にヒット |
def get_user(user_id):
key = f"user:{user_id}"
v = cache.get(key)
if v is not None:
return v # ヒット
v = db.query("SELECT ... WHERE id=?", user_id) # ミス
cache.set(key, v, ttl=300 + random.randint(0, 60)) # ← ジッタを入れる
return v
def update_user(user_id, data):
db.update(...)
cache.delete(f"user:{user_id}") # ← 更新ではなく「削除」する
2 つの更新が並行したとき、「DB 更新 → キャッシュ更新」の順序が入れ替わるとキャッシュに古い値が残り続けます。ただし削除にも race があります。リーダーが更新前の DB 値を読んでから、更新後の削除をまたいで古い値を書き戻す可能性があります。厳密さが必要なら、値にバージョンを付ける、更新と無効化の順序を管理する、CDC/Write-Through を使う、またはキャッシュを認可・整合性境界の外に置くなどの設計を選びます。
20.3キャッシュの 3 大障害(面接必修)
① キャッシュスタンピード / サンダリングハード
人気キーの TTL が切れた瞬間、1 万個の同時リクエストが全部 DB に殺到 → DB が死ぬ
対策
- ミューテックス / シングルフライト:最初の 1 リクエストだけ DB を読み、他は待つ
- 確率的早期期限切れ (XFetch):期限が近づくほど確率的に先回りで更新
- TTL にジッタを加える(同時失効を防ぐ、最重要かつ最も簡単)
- 論理的期限切れ:値と一緒に「論理期限」を持ち、期限切れでも古い値を返しつつ裏で更新
if now - delta * beta * log(random()) >= expiry: # 期限前に確率的に再計算
refresh()
② キャッシュペネトレーション(貫通)
存在しない ID を大量に問い合わせる → 毎回キャッシュミス → 毎回 DB に行く(攻撃にも使われる)
対策:ネガティブキャッシュ(「存在しない」を短い TTL でキャッシュ)、Bloom フィルタで存在チェック(第 26 章)
③ キャッシュアバランチ(雪崩)
Redis クラスタが落ちる / 全キーの TTL が同時に切れる → 全リクエストが DB へ
対策:TTL のジッタ、多層キャッシュ(ローカル + 分散)、サーキットブレーカとレート制限で DB を守る、キャッシュ復旧時のウォームアップ
加点フレーズ
「キャッシュは可用性の依存先になるので、キャッシュが落ちてもサービスが死なない 設計にします。具体的には、キャッシュミス時に DB へ流れる量をレート制限し、 それを超えたリクエストは degraded なレスポンス (古いデータ or 一部機能停止)を返します。」
20.4追い出しポリシー
| ポリシー | 説明 | 向き |
|---|---|---|
| LRU | 最も長く使われていないものを追い出す | 汎用。最も一般的 |
| LFU | 使用頻度が低いものを追い出す | 人気が安定している場合 |
| FIFO | 入った順 | 単純だが性能は劣る |
| TTL | 時間で失効 | 鮮度が重要な場合 |
| W-TinyLFU | LFU の推定(Count-Min Sketch)+ LRU ウィンドウ | ⭐ 現代のベストプラクティス(Caffeine が採用) |
一度きりの大量スキャン(バッチ処理)でキャッシュが全部入れ替わってしまう(キャッシュ汚染)。→ セグメント LRU や W-TinyLFU で緩和。
20.5Redis と Memcached
| Redis | Memcached | |
|---|---|---|
| データ型 | String, List, Hash, Set, Sorted Set, Stream, HLL, Bitmap, Geo | 文字列のみ |
| 永続化 | RDB(スナップショット)/ AOF(追記ログ) | なし |
| レプリケーション | あり(非同期) | なし |
| スレッド | 単一スレッド(I/O は複数化可) | マルチスレッド |
| 用途 | 汎用データ構造サーバー、ランキング、キュー、分散ロック | 純粋なキャッシュ、大きな値、マルチコアを使い切る |
Redis の Sorted Set はランキング設計で必須
ZADD leaderboard 1500 "user:42" ZREVRANGE leaderboard 0 9 WITHSCORES -- 上位 10 人を O(log N + 10) で取得 ZREVRANK leaderboard "user:42" -- 自分の順位
- 単一スレッドなので
KEYS *や大きなLRANGEは全体をブロックする(SCANを使う) - レプリケーションは非同期なので、フェイルオーバー時に書き込みが失われうる
- クラスタモードでは複数キーの操作が同一スロットに限定される(ハッシュタグ
{user1}:profileで制御)
20.6CDN
仕組み:世界中の PoP(Point of Presence)にコンテンツを複製し、Anycast / GeoDNS で最寄りへ誘導する。
ユーザー(東京)→ 東京 PoP(キャッシュヒット → 5 ms で返却)
│ ミス
▼
シールド/リージョナルキャッシュ
│ ミス
▼
オリジンサーバー(米国, 120 ms)
CDN が扱えるもの
- 静的:画像、CSS/JS、動画、フォント、ダウンロードファイル
- 動的:API レスポンス(短い TTL +
stale-while-revalidate)、エッジコンピューティング(Cloudflare Workers 等)で認証・A/B・パーソナライズも可能
| 重要な設計テクニック | 説明 |
|---|---|
| キャッシュキーの正規化 | 不要なクエリパラメータ(utm_source 等)を除外してヒット率を上げる |
| オリジンシールド | 中間層を置き、オリジンへのリクエストを集約する |
| リクエストコアレッシング | 同じキーへの同時ミスを 1 本にまとめてオリジンへ |
| 署名付き URL | 有効期限付きのアクセス許可(有料コンテンツ) |
| キャッシュパージ | バージョン付き URL が基本。パージは全 PoP への伝播に時間がかかる |
| Vary ヘッダ | Vary: Accept-Encoding は良い。Vary: User-Agent はヒット率が壊滅する |
「画像は 200 KB × 2 万 QPS = 4 GB/s になるので、オリジンでは到底捌けません。 CDN に載せ、ファイル名にコンテンツハッシュを含めて immutable を付けます。 オリジンへのリクエストは全体の 1% 以下に抑えられる想定です。」
第 20 章 一問一答
- Cache-Aside で更新時にキャッシュを「更新」ではなく「削除」する理由は。
- 並行更新で順序が逆転し古い値が残るのを防ぐため。
- キャッシュスタンピードの対策を 3 つ。
- シングルフライト(ミューテックス)、TTL ジッタ、確率的早期更新。
- 存在しないキーへの大量アクセス対策は。
- ネガティブキャッシュと Bloom フィルタ。
- Redis で
KEYS *を避ける理由は。 - 単一スレッドのため全リクエストがブロックされる。
CHAPTER 21メッセージキューとストリーム処理基盤
非同期化の設計ができ、Kafka の内部を説明できる。
21.1なぜ非同期にするのか
【同期】ユーザー登録 API DB 保存(20ms) → 確認メール送信(800ms) → 画像生成(1500ms) → レスポンス = 2.3 秒。しかもメール送信が失敗したら登録も失敗する 【非同期】 DB 保存(20ms) → キューに積む(2ms) → レスポンス(22ms!) ワーカーが後からメール送信・画像生成
非同期化の 5 つの効果
- レイテンシ短縮:重い処理をリクエストから外す
- 疎結合:送信側は受信側を知らなくてよい
- バッファリング(平滑化):突発的な負荷をキューが吸収する
- リトライの一元化:失敗したら再処理できる
- ファンアウト:1 つのイベントを複数の消費者へ
結果整合になる、デバッグが難しくなる、「キューが詰まっている」という新しい障害モードが生まれる、順序保証の設計が必要。
21.22 つのモデル
| キュー(ポイントツーポイント) | Pub/Sub(トピック) | |
|---|---|---|
| 配信先 | 1 メッセージを 1 コンシューマが処理 | 全購読者に配信 |
| 用途 | ジョブ処理、タスク分散 | イベント通知、複数システムへの伝播 |
| 例 | SQS, RabbitMQ(queue), Celery | Kafka, Pub/Sub, SNS, Redis Pub/Sub |
21.3Kafka の内部構造(面接必修)
Topic: "orders" ├ Partition 0: [msg0][msg1][msg2][msg3] → ← 追記のみのログ ├ Partition 1: [msg0][msg1][msg2] → └ Partition 2: [msg0][msg1][msg2][msg3][msg4] → 各パーティションは複数ブローカーにレプリケート(leader + follower) コンシューマグループ内では、1 パーティション = 1 コンシューマ
押さえるべき性質
- 順序保証はパーティション内のみ
→ 順序が必要なら同じキーを同じパーティションへ(hash(key) % partitions)。例:同じuser_idのイベントは必ず同じ順で処理される - オフセット管理:コンシューマは「どこまで読んだか」を自分で管理する。→ メッセージは読んでも消えない(保持期間 or サイズで削除)→ 再処理(リプレイ)が可能 ← これが Kafka の最大の価値
- 並列度の上限 = パーティション数
→ コンシューマを増やしてもパーティション数を超えると遊ぶ。パーティション数は後から増やせるが、減らせないし、増やすとキーの割当が変わる - 速さの理由:シーケンシャル I/O(追記のみ)、ゼロコピー(
sendfileでカーネル空間からそのまま NIC へ)、バッチング + 圧縮、ページキャッシュの活用
ISR (In-Sync Replicas) と acks
acks=0 : 送りっぱなし。最速・最も危険 acks=1 : リーダーが書けば OK。リーダー障害で失う可能性 acks=all : ISR 全員が書いたら OK。min.insync.replicas=2 と併用が定番
21.4配信保証とコンシューマ設計
at-most-once : 処理前にオフセットをコミット(失敗すると失われる) at-least-once: 処理後にオフセットをコミット(重複しうる)← 基本
at-least-once + 冪等な処理(第 18 章)。
| 論点 | 設計 |
|---|---|
| バックプレッシャ | コンシューマが遅いとラグが増える。ラグを監視し、オートスケールする |
| ポイズンメッセージ | 何度やっても失敗するメッセージが先頭で詰まる → DLQ(Dead Letter Queue)へ退避 |
| リトライ | 指数バックオフ。即時リトライは負荷を増やすだけ。リトライ用トピックを分ける |
| リバランス | コンシューマ増減時にパーティションが再割当される。処理中断が起きる(協調的リバランスで緩和) |
| 順序と並列の両立 | キー単位で順序、キー間は並列。単一パーティション内でもキーごとにワーカーを分ける手法がある |
21.5製品の使い分け
| 製品 | 特徴 | 向いている用途 |
|---|---|---|
| Kafka | 高スループット、永続ログ、リプレイ可 | イベントストリーム、ログ収集、CDC、分析基盤 |
| RabbitMQ | 柔軟なルーティング、優先度、遅延配信 | タスクキュー、複雑なルーティング |
| SQS | フルマネージド、スケール上限とクォータを持つ | 一般的なジョブキュー(順序が要るなら FIFO キュー)。上限、可視性、重複配信を確認 |
| Redis Streams | 軽量・低レイテンシ | 小〜中規模、既に Redis がある場合 |
| Pulsar | ストレージと計算の分離、マルチテナント | 大規模マルチテナント |
「イベントを複数のコンシューマ(検索インデックス更新・通知・分析)が 別々のペースで消費し、障害時に過去に遡って再処理したいので Kafka を選びます。 単純なジョブキューであれば運用が楽な SQS で十分です。」
21.6キューの落とし穴
- キューが伸び続けるなら、コンシューマの処理能力が足りていないだけ。キューは問題を「見えなくする」ことがある
- キュー滞留時間 = ラグ。ユーザーが待つ処理をキューに入れると、「送信しました」と言いつつ 10 分後に届く、という体験になる
- リトライストーム:全メッセージが同時に失敗して同時にリトライし、下流を殺す → ジッタ + サーキットブレーカ
第 21 章 一問一答
- Kafka で順序が保証される単位は。
- パーティション内のみ。同じキーは同じパーティションへ送ることで順序を担保する。
- Kafka の並列度の上限は何で決まるか。
- パーティション数(1 パーティション = 1 コンシューマ)。
- DLQ の目的は。
- 何度も失敗するメッセージを退避し、後続の処理をブロックしないため。
- Kafka が速い理由を 3 つ。
- シーケンシャル I/O、ゼロコピー、バッチング+圧縮(+ページキャッシュ活用)。
CHAPTER 22全文検索とベクター検索(セマンティック検索・ANN)
キーワード検索(転置インデックス)と意味検索(ベクター検索・ANN)の仕組みを理解し、現代のハイブリッド検索システムをゼロから設計できるようになる。
22.1なぜ LIKE '%keyword%' ではダメか
SELECT * FROM posts WHERE content LIKE '%システム設計%';
- インデックスが使えない → 全行スキャン(1 億行なら数十秒)
- 語彙の不一致問題 (Vocabulary Mismatch Problem):「おいしい林檎の切り方」で検索したとき、「アップルの皮むき手順」という文書はヒットしない
- 表記ゆれ・語形変化・同義語に対応できない
- スコアリング(どの文書がより関連しているかの順位付け)ができない
22.2転置インデックス(倒置索引)
【元データ】 doc1: "分散システムの設計" doc2: "システム設計の面接" doc3: "分散データベース" 【転置インデックス】(単語 → その語を含む文書リスト) "分散" → [doc1, doc3] "システム" → [doc1, doc2] "設計" → [doc1, doc2] "面接" → [doc2] "データベース" → [doc3] 検索「分散 AND 設計」→ [doc1,doc3] ∩ [doc1,doc2] = [doc1]
原文 → ① 文字正規化(全角半角、大文字小文字、Unicode 正規化)
→ ② トークン化(英語は空白、日本語は形態素解析 or N-gram)
→ ③ ストップワード除去("the", "の")
→ ④ ステミング/正規化(running → run、送り仮名の統一)
→ ⑤ 同義語展開(PC = パソコン)
→ 転置インデックスへ
分かち書きがないので、
- 形態素解析(Kuromoji, MeCab):辞書ベース。精度が高いが未知語に弱い
- N-gram(bi-gram):「システ」「ステム」…と機械的に分割。取りこぼしがないがインデックスが巨大でノイズ(偽陽性)が多い
→ 実務では両方のフィールドを作り、OR で検索するのが定石。
22.3スコアリング(TF-IDF と BM25)
スコア = 出現回数(TF) × 希少度(IDF)
- TF:その文書に何回出てくるか(多いほど関連が強い)
- IDF:全文書のうち何割に出てくるか(レアな語ほど重要)
TF-IDF の改良版で現代のキーワード検索の標準。TF の効果を飽和させ(10 回出現しても 100 回出現してもスコアが無限に伸びないよう上限を設ける)、文書長で正規化する(長い文書ほど単語が多くなるペナルティを与える)。
22.4Elasticsearch / OpenSearch の内部設計
Index(テーブル相当)
└ Shard(Lucene インデックス 1 つ = 独立した検索単位)
├ Primary shard
└ Replica shard(読み取り分散 + 冗長化)
| 論点 | 指針 |
|---|---|
| シャード数 | 変更には split/shrink/reindex など製品ごとの手順が必要。1 シャード 10〜50GB はワークロードと運用上限から決める目安 |
| 更新の反映 | refresh_interval(既定 1 秒)。準リアルタイムであり即時ではない |
| 更新コスト | Lucene セグメントはイミュータブル。更新 = 削除マーク + 新規追加 → マージが走る |
| 真実の情報源 | しない。DB を真実の情報源とし、ES は再構築可能な派生データとする |
| 時系列データ | 日次インデックス + エイリアス、ILM(古いものを安いノードへ、最後は削除) |
① 二重書き込み(アプリが両方に書く)→ ❌ 不整合が必ず起きる ② Outbox / CDC(Debezium が MySQL binlog を読んで Kafka へ)→ ✅ 推奨 ③ 定期バッチで全件再構築 → 補助として併用(ドリフト修正)
22.5ベクター検索(Dense Retrieval)の基礎
キーワード検索が「単語の一致」を見るのに対し、ベクター検索は「意味の近さ(意味空間上の距離)」を見ます。
テキスト "犬が公園を走る" ──[Embedding モデル]──► [0.12, -0.85, 0.44, ..., 0.03] (768〜1536次元) テキスト "子犬が外で遊ぶ" ──[Embedding モデル]──► [0.15, -0.81, 0.40, ..., 0.05] → 高次元空間での距離(コサイン類似度・内積)が非常に近い!
| 類似度の尺度 | 計算式 | 特徴・使いどころ |
|---|---|---|
| コサイン類似度 | cos(θ) = (A·B) / (‖A‖‖B‖) | ベクトルの長さ(文章量)を無視し、方向(意味)の一致度だけを測る(-1〜1) |
| 内積 (Dot Product) | A·B = Σ AᵢBᵢ | ベクトルが正規化(長さ=1)されていればコサイン類似度と等価。計算が最速 |
| ユークリッド距離 (L2) | √(Σ (Aᵢ − Bᵢ)²) | 空間上の直線距離。距離が小さいほど類似 |
1 ベクトル = 1536 次元 (OpenAI text-embedding-3 等) × 4 バイト (float32) ≒ 6 KB ・1,000 万文書 = 10^7 × 6 KB = 60 GB ・1 億文書 = 10^8 × 6 KB = 600 GB(純ベクトルデータのみ) ※ 後述の HNSW グラフインデックスのオーバーヘッドで約 1.5〜2 倍(1〜1.2 TB)になるため、 メモリ見積もりと量子化(Quantization)の検討が不可欠。
22.6近似最近傍探索(ANN: Approximate Nearest Neighbor)
1 億件のベクトルに対して、クエリベクトルとの距離を全件計算(ブルートフォース:O(N·D))すると、1 回の検索に数秒かかり実用になりません。そこで「100% 正確な最寄りではなく、精度とレイテンシの目標をベンチマークで決め、近似候補を探す」アルゴリズム(ANN)を使います。
┌─────────────────────────────────────────────────────────────┐ │ ANN アルゴリズムの比較 │ ├──────────────────┬──────────────────┬───────────────────────┤ │ HNSW │ IVF-PQ │ ScaNN (Google) │ ├──────────────────┼──────────────────┼───────────────────────┤ │ グラフベース │ クラスタ + 積量子化│ クラスタ + 異方性量子化│ │ 高精度・超高速 │ メモリ 90% 削減 │ 高速・高再現率 │ │ メモリ消費 大 │ 精度はやや低下 │ Google 検索/YouTube │ └──────────────────┴──────────────────┴───────────────────────┘
① HNSW(Hierarchical Navigable Small World)
- 仕組み:スキップリストのグラフ版。多層の近傍グラフを構築する。最上位層はまばらなノード間を大股でジャンプし、クエリに近い領域を高速特定。最下位層は密なノード間を細かく探索し、高精度な近傍を見つける
- 長所:適切なパラメータなら高速な近似検索を実現できる。p99、Recall、メモリは次元数、データ分布、ef/search 幅、ハードウェア、負荷でベンチマークする
- 短所:メモリ消費が巨大(生ベクトルに加え、各ノードのグラフエッジ情報で 1.5〜2 倍の RAM が必要)
② IVF-PQ(Inverted File + Product Quantization)
- IVF(転置ファイル):空間を k-means で K 個(例:4096 個)のボロノイ領域(クラスタ)に分割。クエリが属する最寄り数クラスタのベクトルだけを探索対象にする(探索範囲の劇的削減)
- PQ(積量子化):1536 次元のベクトルを 8 個のサブベクトル(各 192 次元)に分割し、各サブベクトルを 256 個の代表値(重心)の ID(1 バイト)に置き換える → 6,144 バイト (float32) が、わずか 8 バイト(8 個の 1Byte ID)に圧縮(99% 以上のメモリ削減!)
- 長所:メモリ効率が極めて高い。圧縮率、メタデータ、検索精度を測ったうえで、1 億件を数十 GB 規模の RAM に収められる場合がある
- 短所:量子化による精度低下があり得る。Recall は圧縮方式、学習データ、検索パラメータで測定する
③ ScaNN(Scalable Nearest Neighbors — Google 開発)
- Google のイノベーション:異方性量子化 (Anisotropic Quantization)
- 仕組み:量子化の誤差を計算する際、「内積(距離)の平行方向の誤差」を厳しく罰し、「直交方向の誤差」を許容する。これにより、量子化しながらも内積の順位逆転を最小化
- 用途:Google 検索、YouTube 推薦、Google Cloud Vertex AI Search のバックエンド
22.7ハイブリッド検索と RRF(Reciprocal Rank Fusion)
実務の検索において、ベクター検索単体でキーワード検索を完全に置き換えることはできません。
| 検索方式 | 得意なこと | 苦手なこと |
|---|---|---|
| キーワード検索 (BM25) | 商品型番(A-1234)、固有名詞、エラーコード、完全一致 |
抽象的な概念、言い換え、長文の質問 |
| ベクター検索 (Dense) | 意図の理解、意味の類似(「安い宿」=「格安ホテル」) | 型番の完全一致、否定表現、専門用語 |
異なるスコア体系(BM25 の実数値スコアと、コサイン類似度の 0〜1 スコア)をそのまま足し算することはできません。「順位(Rank)」だけを使って安全に統合するアルゴリズムが RRF です。
RRF_Score(d) = Σ[m ∈ M] 1 / (k + r_m(d)) M : 検索エンジンの集合(BM25 と ベクター検索) r_m(d) : エンジン m における文書 d の順位(1位なら 1, 2位なら 2...) k : 定数(通常 k = 60 が標準)
【例: 文書 A】 ・BM25 で 1 位 → 1 / (60 + 1) = 0.01639 ・Vector で 3 位 → 1 / (60 + 3) = 0.01587 → 合計 RRF スコア = 0.03226(最上位候補)
22.8メタデータフィルタリング(Filtered Vector Search)
「東京都内のホテルで、評価 4.0 以上(メタデータ条件)の中で、クエリに最も近いもの」を探す場合、ナイーブな実装では破綻します。
❌ Pre-filtering(先に SQL 等で絞り込んでから ANN)
→ 候補が 1 万件に減った状態でグラフ探索すると、
グラフが途切れて ANN が辿り着けない(孤立ノード問題)
❌ Post-filtering(ANN で上位 100 件取ってからメタデータで絞る)
→ 上位 100 件の中に条件合致が 1 件もなく、検索結果が 0 件になる
✅ Single-Stage / Iterative Filtered Search(グラフ探索中にメタデータを評価)
→ HNSW の探索ステップごとに「メタデータ条件を満たすか」を
ビットマップ等で高速チェックしながら巡回
「商品検索では BM25 と HNSW ベクター検索のハイブリッド検索を採用し、 結果を RRF(k=60)で統合します。 型番やブランド名による完全一致は BM25 で確実に拾い、 自然文での意図検索は Embedding でカバーします。 価格やカテゴリの絞り込みには、グラフ探索中にビットセットで条件評価を行う Iterative Filtering を使い、結果の取りこぼしを防ぎます。」
第 22 章 一問一答
- 転置インデックスとベクター検索の決定的な違いは。
- 転置インデックスは単語の完全一致と出現頻度を見るが、ベクター検索は高次元空間での意味的な近さ(コサイン類似度/内積)を見る。
- HNSW と IVF-PQ のトレードオフは。
- HNSW はメモリを多く使う代わりに調整次第で高い Recall を得やすい。IVF-PQ は圧縮でメモリを減らせるが、削減率と Recall 低下はデータ・パラメータで実測する。
- ハイブリッド検索で RRF を使う理由は。
- BM25 とベクター類似度ではスコアの尺度や分布が異なるため、順位(Rank)のみを使って安全かつロバストに統合できるから。
- Post-filtering(ベクター検索後のメタデータ絞り込み)の問題点は。
- ANN で取得した上位候補の中に条件を満たすものが存在せず、結果が 0 件になるリスクがある。
CHAPTER 23オブジェクトストレージとファイル配信
画像・動画のアップロードと配信を、DB に入れずに設計できる。
23.1なぜ DB にファイルを入れないのか
大きな BLOB を RDB に入れると、バックアップ、バッファプール、レプリケーション、配信が重くなることがあります。一方、サイズが小さく、トランザクションと一緒に保存する必要があるデータでは RDB が合理的な場合もあります。サイズ、更新頻度、アクセス経路、バックアップ、暗号化、CDN 要件で決めます。
✅ 大きな・頻繁に配信するファイルは、オブジェクトストレージ(S3 / GCS)に置き、DB にはメタデータとキーを置く構成が一般的です。
23.2オブジェクトストレージの性質
| 性質 | 内容 |
|---|---|
| データモデル | key → オブジェクト(+メタデータ)。ディレクトリは擬似的(/ はキーの一部) |
| 操作 | PUT / GET / DELETE / LIST。部分更新はできない(全体を置き換える) |
| 耐久性 | 事業者、リージョン、ストレージクラス、冗長化設定ごとの保証値を確認 |
| 一貫性 | オブジェクトストレージ製品・操作・リージョン構成ごとの read-after-write、LIST、削除の保証を確認 |
| コスト | 保存が非常に安く、リクエスト数と外向き転送で課金される |
| ストレージクラス | Standard / Infrequent Access / Glacier など。取り出し料金、最小保存期間、遅延を含めて料金表で評価 |
23.3アップロードの設計(面接頻出)
❌ 悪い設計:クライアント → アプリサーバー → S3(アプリサーバーの帯域とメモリを浪費し、スケールしない)
① クライアント → アプリ:「upload.jpg を上げたい(10 MB, image/jpeg)」 ② アプリ: 権限チェック → S3 の署名付き PUT URL を発行(有効期限 15 分) ③ クライアント → S3 に直接 PUT ← アプリサーバーを経由しない ④ S3 のイベント通知 → Lambda/キュー → サムネイル生成・DB のステータス更新
- 5 MB〜100 MB のチャンクに分割して並列アップロード
- 失敗したチャンクだけ再送 → レジューム可能
- 完了時に S3 側で結合
アップロードセッションを DB に記録: upload_id, 総サイズ, 完了チャンクのビットマップ → 中断後は「どこから再開すればいいか」をクライアントに返せる
23.4配信の設計
非公開ファイル: 署名付き GET URL(有効期限付き)→ CDN 経由 公開ファイル: CDN に長期キャッシュ + コンテンツハッシュ付きファイル名
アップロード → トランスコード(複数解像度・ビットレート) 1080p/5Mbps, 720p/2.5Mbps, 480p/1Mbps, 360p/600kbps → セグメント化(HLS/DASH: 2〜10 秒ごとの .ts / .m4s ファイル + マニフェスト) → CDN へ配置 → プレイヤーが帯域を測定し、セグメント単位で品質を切替(ABR: Adaptive Bitrate)
ABR が重要な理由:「再生開始の速さ」と「途中で止まらないこと」は画質より体験に効きます。低画質で即開始し、徐々に上げるのが定石です。
23.5重複排除とストレージ効率
- コンテンツアドレス指定:ファイルの SHA-256 をキーにする → 同じファイルは 1 つしか保存されない(Dropbox 型の設計)
- チャンクレベルの重複排除:可変長チャンク(Rabin fingerprint)に分割してハッシュ化 → ファイルの一部だけ変更されたとき、変更チャンクだけ転送すればよい
- デルタ同期 (rsync):差分だけを送る
「ファイルを 4 MB の可変長チャンクに分割し、各チャンクの SHA-256 をキーにして 保存します。クライアントは同期前にチャンクハッシュのリストをサーバーに送り、 サーバーが持っていないチャンクだけをアップロードします。 これによりストレージも帯域も大幅に節約でき、 同じファイルを複数人が持つ場合の重複も自動的に排除されます。」
第 23 章 一問一答
- 署名付き URL の利点は。
- アプリサーバーを経由せず直接ストレージと通信でき、帯域とスケールの問題を回避できる。
- マルチパートアップロードの利点は。
- 並列化による高速化と、失敗チャンクだけの再送(レジューム)。
- ABR ストリーミングとは。
- 複数ビットレートのセグメントを用意し、クライアントが帯域に応じて動的に品質を切り替える方式。
CHAPTER 24レート制限
5 つのアルゴリズムを説明でき、分散環境での実装を設計できる。
24.1なぜ必要か
- サービス保護:過負荷・DDoS からの防御
- 公平性:1 ユーザーが全体を占有しない
- コスト管理:従量課金のバックエンド(LLM API など)の暴走防止
- 課金プラン:Free は 100 req/h、Pro は 10,000 req/h
24.25 つのアルゴリズム
① 固定ウィンドウカウンタ
key = "user:42:2026-08-22T10:00" → INCR、TTL 60 秒。上限を超えたら拒否
- ✅ 極めて単純、メモリ効率が良い
- ❌ 境界問題:10:00:59 に 100 回、10:01:00 に 100 回 → 2 秒間で 200 回通ってしまう(上限の 2 倍)
② スライディングウィンドウログ
Redis の Sorted Set にタイムスタンプを全部記録 ZREMRANGEBYSCORE(古いものを削除)→ ZCARD(件数)→ 上限内なら ZADD
- ✅ 完全に正確
- ❌ メモリを大量消費(全リクエストのタイムスタンプを保持)
③ スライディングウィンドウカウンタ(実務の定番)
現在のウィンドウのカウント + 前のウィンドウのカウント × (はみ出し割合) 例: 10:01:15 時点 → count(10:01) + count(10:00) × (45/60)
- ✅ メモリ効率が良く、境界問題もほぼ解消。近似だが実用上十分
④ トークンバケット(最も広く使われる)
バケットに毎秒 r 個のトークンを補充(上限 b 個)
リクエスト時に 1 個消費。無ければ拒否(or 待つ)
┌─────────┐
│ ●●●●● │ ← 毎秒 r 個補充、最大 b 個まで貯まる
└────┬────┘
▼ リクエストが 1 個消費
- ✅ バースト(急な集中)を許容できる(貯めたトークン分)
- ✅ 状態が 2 つ(トークン数・最終更新時刻)だけで済む → メモリ効率が最高
- 実装:「経過時間 × r」を遅延計算で補充する(タイマー不要)
⑤ リーキーバケット
リクエストをキューに入れ、一定速度で処理する
- ✅ 出力レートが完全に一定(下流を守るのに最適)
- ❌ バーストを許容しない、キューによる遅延が発生
| 目的 | アルゴリズム |
|---|---|
| API のレート制限(汎用) | トークンバケット |
| 下流を一定速度で守る | リーキーバケット |
| メモリ最小で近似で良い | スライディングウィンドウカウンタ |
| 厳密な課金 | スライディングウィンドウログ |
24.3分散環境での実装
[LB] → [APIサーバー × 50 台] → 共有の Redis でカウント
各サーバーがローカルにカウントすると、50 台 × 100 req = 5000 req が通ってしまう。
| 方式 | 説明 | トレードオフ |
|---|---|---|
| 中央 Redis | 全サーバーが 1 つの Redis を見る。Lua スクリプトで原子的に | 正確だがレイテンシ +1 RTT、Redis が SPOF |
| ローカル + 定期同期 | ローカルで数え、100 ms ごとに集約 | 高速だが一時的に超過を許す |
| クォータ分配 | 各サーバーに上限/N を配分し、余ったら融通 | Google の Doorman 方式。スケールする |
| ステートレス(トークン埋め込み) | 事前に発行したトークンを消費 | 実装が複雑 |
-- トークンバケットの例。GET/SET を分けると競合するので Lua で原子的に実行する
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or capacity)
local last = tonumber(redis.call('HGET', KEYS[1], 'ts') or now)
tokens = math.min(capacity, tokens + (now - last) * rate)
if tokens >= 1 then
redis.call('HMSET', KEYS[1], 'tokens', tokens - 1, 'ts', now)
return 1 -- 許可
else
return 0 -- 拒否
end
24.4レート制限の設計上の注意
API キー / ユーザー ID ← 認証済みなら最良 IP アドレス ← 未認証時。ただし NAT 配下の企業ユーザーを巻き込む IP + エンドポイント ← ログイン試行など、危険な操作は厳しく テナント / 組織 ← B2B SaaS
HTTP/1.1 429 Too Many Requests Retry-After: 30 RateLimit-Limit: 100 RateLimit-Remaining: 0 RateLimit-Reset: 1755855600
Retry-After はクライアントに再試行時刻を伝える有用なヘッダです。規格上必須とは限りませんが、返す場合は値を正しく設定し、クライアント側にも指数バックオフ、ジッタ、リトライ予算を実装します。
レート制限は 1 箇所ではなく、CDN/WAF(IP 単位・粗い)→ API ゲートウェイ(ユーザー単位)→ サービス内(機能単位)の多層で行います。
第 24 章 一問一答
- 固定ウィンドウの境界問題とは。
- ウィンドウの境界をまたぐと短時間に上限の 2 倍まで通ること。
- トークンバケットの利点は。
- バーストを許容しつつ平均レートを守れ、状態が小さい。
- 分散レート制限で Redis を使う際の注意は。
- 読み書きを Lua で原子化する。レイテンシと SPOF 化にも備える。
- 429 で返すべきヘッダは。
Retry-After(および RateLimit-* 系)。
CHAPTER 25分散 ID 生成
用途に応じた ID 方式を選べる。
25.1要件
- 一意(全世界で衝突しない)
- 高スループット(数万〜数百万/秒)
- 単調増加が望ましい(DB のインデックス効率、時系列ソート)
- 短い・推測されにくい(URL に使う場合)
この 4 つは互いに矛盾します。「推測されにくい」と「単調増加」は両立しません。
25.2手法の比較
| 手法 | 長さ | ソート可能 | 分散 | 欠点 |
|---|---|---|---|---|
| DB の AUTO_INCREMENT | 8 B | ✅ | △ 単一 DB の採番境界に依存 | 単一 DB の書き込み・フェイルオーバー要件を確認。必ず SPOF とは限らない |
| UUID v4(ランダム) | 16 B | ❌ | ✅ 完全に独立 | インデックスが断片化、長い |
| UUID v7 / ULID | 16 B | ✅(時刻順) | ✅ | 時刻が漏れる |
| Snowflake(Twitter) | 8 B | ✅ | ✅ | 時計の巻き戻しに弱い、ノード ID 管理が必要 |
| チケットサーバー / セグメント | 8 B | ✅ | △ | 中央サーバーが必要(ただしバッチ払い出しで軽い) |
| ハッシュ + 短縮 | 6〜8 文字 | ❌ | ✅ | 衝突チェックが必要 |
25.3Snowflake ID(面接必修)
1 bit 41 bits 10 bits 12 bits ┌──┬────────────────────┬──────────┬────────────┐ │0 │ タイムスタンプ(ms) │ ノードID │ シーケンス │ = 64 bit └──┴────────────────────┴──────────┴────────────┘ ↑符号ビット(通常0) ↑1024ノード ↑同一ms内で4096個
1 ノードあたり同一ミリ秒に 4096 個という設計上限です。実際の発行率は、時計待ち、永続化、ノード管理、競合、フェイルオーバーで決まります。41 bit の期間は基準時刻から約 69 年ですが、仕様として明記します。
| 実装上の問題 | 対策 |
|---|---|
| 時計の巻き戻し(NTP 補正) | 巻き戻しを検知したら、追いつくまで待つ or エラーにする |
| ノード ID の割当 | ZooKeeper/etcd で採番、または k8s の StatefulSet の序数、MAC アドレス |
| 同一 ms 内で 4096 個を超えた | 次の ms まで待つ(スピン) |
| ID から時刻・順序が推測できる | 公開 ID には別途、認可、レート制限、署名付き参照、暗号化などを使う。Hashids は暗号化や認可の代わりではない |
25.4用途別の選び方
DB の主キー(InnoDB) → Snowflake / UUIDv7 / ULID(単調増加が必須) 公開 URL の ID → 推測不可能な ID(ランダム or 暗号化した連番) 短縮 URL → Base62 の連番 or ハッシュ先頭 7 文字 + 衝突時に再試行 分散トレース ID → UUID v4 / 128 bit ランダムで十分 冪等性キー → クライアント生成の UUID v4
62 文字 [0-9a-zA-Z] 6 文字 → 62^6 = 568 億通り 7 文字 → 62^7 = 3.5 兆通り ← 実用上ここが定番
25.5セグメント(バッチ)方式 — 実用的で堅牢
アプリノードが DB から「1000 個分の範囲」をまとめて確保する UPDATE id_segments SET max_id = max_id + 1000 WHERE biz='order' RETURNING max_id; → ノードはメモリ内で 1000 個を配る(DB アクセスは 1000 回に 1 回) → 半分使ったら非同期で次の範囲を先読み確保(バッファ枯渇を防ぐ)
- ✅ DB 負荷が 1/1000、単調増加、実装が単純
- ❌ ノード再起動で番号が飛ぶ(実害はない)
これは美団の Leaf や多くの実サービスで使われている、非常に実用的な方式です。
第 25 章 一問一答
- UUID v4 を InnoDB の主キーにすべきでない理由は。
- ランダムなためクラスタ化インデックスへの挿入が分散し、ページ分割と断片化を招く。
- Snowflake の 64 bit の内訳は。
- 符号 1 + タイムスタンプ 41 + ノード ID 10 + シーケンス 12。これは識別子の形式であり、全ノードの厳密な実時間順序を保証しない。
- Snowflake の最大の運用リスクは。
- 時計の巻き戻しによる ID 重複。検知して待機する必要がある。
CHAPTER 26確率的データ構造
「正確さを少し捨てて、メモリを 1/100 にする」道具を使い分けられる。
26.1Bloom フィルタ
「絶対に無い」を高速・省メモリで判定する。
m ビットの配列と k 個のハッシュ関数
追加: h1(x), h2(x), h3(x) のビットを 1 にする
検査: 全部 1 なら「たぶんある」、1 つでも 0 なら「絶対にない」
0 1 0 1 1 0 0 1 0 1 0 0
↑ ↑ ↑
h1(x) h2(x) h3(x)
- 偽陽性 (false positive) はあるが、偽陰性 (false negative) はない
- 誤検知率 1% なら1 要素あたり約 9.6 bit(10 億要素で 1.2 GB)
- 誤検知率 0.1% なら 1 要素あたり約 14.4 bit
- 削除できない(Counting Bloom Filter なら可能)
- 最適なハッシュ数
k = (m/n) × ln2
| 使いどころ | 説明 |
|---|---|
| LSM-Tree の SSTable | 「このファイルにこのキーは無い」を即判定し、無駄な I/O を防ぐ |
| キャッシュペネトレーション防止 | 存在しない ID への DB アクセスを遮断 |
| Web クローラ | 訪問済み URL の判定 |
| ユーザー名の重複チェック | 「たぶん使われている」なら DB で確認 |
Cuckoo フィルタ:削除可能で、低誤検知率では Bloom より省メモリ。
26.2HyperLogLog — ユニーク数の推定
1.5 KB で数十億のユニーク数を、誤差 2% 以内で数える。
仕組み: ハッシュ値の先頭の 0 の連続数を見る 「先頭に 0 が 10 個続く値が観測された」→ 要素数は 2^10 ≒ 1024 くらいだろう これを多数のバケットで平均(調和平均)して精度を上げる
標準誤差 = 1.04 / √m。m=16384 バケットで誤差 0.81%、メモリ 12 KB。
使いどころ:ユニークビジター数、ユニーク検索クエリ数、「この動画を見たユニークユーザー数」など。
和集合は取れるが、積集合は精度が落ちる。
PFADD visitors:2026-08-22 user1 user2 user3 PFCOUNT visitors:2026-08-22 PFMERGE visitors:week visitors:day1 visitors:day2 ... -- 和集合が取れる
26.3Count-Min Sketch — 頻度の推定
「どの要素が何回出たか」を固定メモリで近似する。
d 個のハッシュ関数 × w 個のカウンタの 2 次元配列 追加: 各行の hash_i(x) 番目のカウンタを +1 問合: 各行の値の最小値を返す(衝突による過大評価を最小値で抑える)
- 常に実際の値以上(過小評価しない)
- ヘビーヒッター検出(アクセスが多い上位 K 個)に使う
- 使いどころ:レート制限の粗いカウント、トレンド検出、キャッシュの人気度推定(W-TinyLFU)
26.4その他
| 構造 | 用途 |
|---|---|
| t-digest / DDSketch | 分散環境でのパーセンタイル(p99)の近似集計。マージ可能 |
| Top-K / Space-Saving | 上位 K 個の頻出要素 |
| MinHash / SimHash | 集合の類似度・near-duplicate 検出(重複記事の除去) |
| スキップリスト | 確率的な平衡構造。Redis の Sorted Set、LSM の MemTable |
「1 日のユニークビジター数を正確に出すには全ユーザー ID を保持する必要があり、 1 億人なら数 GB になります。しかし表示は概算で十分なので、 HyperLogLog を使えば 12 KB・誤差 1% 未満で済みます。 課金に関わる正確な数値が必要な部分だけ、日次バッチで正確に集計します。」
これは「要件に応じて精度とコストを交換できる」という強いシグナルです。
第 26 章 一問一答
- Bloom フィルタの偽陰性はあるか。
- ない。偽陽性のみ発生する。
- HyperLogLog のメモリと誤差は。
- 約 12 KB で誤差 1% 未満(数十億要素まで)。
- Count-Min Sketch が過小評価しない理由は。
- 衝突は加算方向にのみ働き、問い合わせで最小値を採用するため。
CHAPTER 27地理空間インデックス
「近くの店舗 / ドライバーを探す」を効率的に設計できる。
27.1素朴な方法の限界
SELECT * FROM drivers WHERE SQRT(POW(lat-?,2) + POW(lng-?,2)) < 0.05; -- 全行スキャン。100 万台で数秒
2 次元の距離は B-Tree インデックスで扱えません(1 次元しか順序付けできない)。→ 2 次元を 1 次元に落とすのが地理空間インデックスの本質です。
27.2主要な手法
① Geohash(最も使いやすい)
緯度経度を交互にビット分割し、Base32 文字列にエンコードする "xn774c" ← 東京駅付近 文字数と精度(緯度で幅が変わる概算): 4 文字 → 約 20 km 四方 5 文字 → 約 5 km 6 文字 → 約 1.2 km 7 文字 → 約 150 m 8 文字 → 約 38 m 前方一致 = 近い! → 通常の文字列インデックス(B-Tree)でそのまま検索できる SELECT * FROM shops WHERE geohash LIKE 'xn774%';
セルの端にいると、隣接セルの近い店が見つからない。→ 自分のセル + 隣接 8 セルの計 9 個を検索するのが定石。
② QuadTree(四分木)
領域を再帰的に 4 分割し、1 ノードあたりの点が閾値を超えたら分割する → 密度に応じて適応的(都心は細かく、地方は粗く)
- ✅ 密度の偏りに強い
- ❌ 木構造をメモリに保持する必要があり、更新(移動体)に弱い
③ S2 (Google) / H3 (Uber)
- S2:地球を立方体に投影し、ヒルベルト曲線で 1 次元化。球面上の面積の偏りを抑えるが、完全に同一ではない
- H3:六角形を基本とする階層グリッド。セル形状には五角形などもあり、距離が全方向で完全に等しいわけではない。配車・配送では隣接セルと道路距離を評価する
④ PostGIS / R-Tree
CREATE INDEX ON shops USING GIST (location); SELECT * FROM shops WHERE ST_DWithin(location, ST_MakePoint(?,?)::geography, 1000) -- 1km 以内 ORDER BY location <-> ST_MakePoint(?,?) LIMIT 10; -- KNN 検索
- ✅ 多角形・経路など複雑な形状も扱える。SQL で完結
- ❌ 超高頻度更新(数百万台の位置更新)には向かない
⑤ Redis GEO
GEOADD drivers 139.767 35.681 "driver:42" GEOSEARCH drivers FROMLONLAT 139.77 35.68 BYRADIUS 3 km ASC COUNT 10
内部は Sorted Set + Geohash スコア。移動体のリアルタイム検索に最適。
27.3設計パターン:Uber 型の配車
【ドライバーの位置更新】4 秒ごと × 100 万台 = 25 万 QPS → 永続 DB には書かない。Redis (GEO) or メモリ上のグリッドに保持 → 履歴が必要なら Kafka → オブジェクトストレージへ非同期に流す 【近傍検索】 ① 乗客の位置から H3/Geohash セルを算出 ② 自セル + 隣接セルのドライバーを取得(数十〜数百人) ③ 直線距離でフィルタ → 実際の道路距離/ETA を計算(ここが重い) ④ スコアリング(距離・評価・車種)してマッチング 【スケール】 地理でシャーディング(都市単位)→ 都市間のトラフィックは独立 ホットスポット(渋谷駅)はセルをさらに細分化
「位置情報は書き込みが 25 万 QPS と多く、かつ数秒で陳腐化するので、 永続化せずインメモリのグリッド(Redis GEO)に保持します。 検索はセル単位で行い、境界問題に対応するため隣接セルも含めて 9 セルを検索します。 都市ごとにシャーディングすれば、地理的にトラフィックが自然に分離されます。」
第 27 章 一問一答
- Geohash の前方一致が使える理由は。
- 空間充填曲線により近い地点が似た接頭辞を持つため。
- Geohash の境界問題への対処は。
- 隣接 8 セルを含む計 9 セルを検索する。
- QuadTree の利点は。
- 密度に応じて適応的に分割するため、偏った分布でも効率が落ちにくい。


