非機能 (2) 性能 ─ 集約トラフィックを捌く
可用性が「自分が止まると全員が止まる」だとしたら、性能は「全員のリクエストが自分に集まってくる」立場。利用者ドメインなら 100 req/s で済むワークロードが、共通基盤では 10,000 req/s や 100,000 req/s になる。
この章では集約トラフィックの捌き方と、Backpressure / Cache 階層の設計を扱う。
集約トラフィックの構造
graph LR
subgraph "利用者側"
U1[サービス A<br/>1k req/s]
U2[サービス B<br/>3k req/s]
U3[サービス C<br/>500 req/s]
U4[サービス D<br/>2k req/s]
U5[サービス E<br/>...]
end
subgraph "共通基盤"
P[認証基盤<br/>合計 50k req/s]
end
U1 --> P
U2 --> P
U3 --> P
U4 --> P
U5 --> P
Note1[1 サービスの req は小さくても、<br/>全部集まると桁が違う]
style P fill:#e1f5ff
スケール特性として:
- トラフィックが集約される(利用者数 × 利用者ごとのトラフィック)
- ピークが重なる(朝 9 時の出社、夜 8 時のニュース、月初のバッチ)
- 障害時に倍増する(利用者の retry が殺到)
性能予算(Performance Budget)
可用性に Error Budget があるように、性能には Performance Budget:
| 指標 | 目標 |
|---|---|
| p50 latency | 中央値、典型ユーザー |
| p95 latency | 上位 5% も満足 |
| p99 latency | 上位 1% でも許容範囲 |
| p999 latency | 異常な遅延の指標 |
共通基盤では特に p99 / p999 が重要:
利用者の 1 req は基盤の 5 req になることが珍しくない。基盤の p99 が 100ms なら、利用者の p95 が 500ms になる。
長尾(tail latency)が利用者全員に倍増して波及する。Google のチーフエンジニア Jeff Dean が “The Tail at Scale” で論じた問題。
Cache 階層の設計
性能改善の第一手は Cache 階層。共通基盤は read 圧倒的なものが多いので、cache が劇的に効く。
graph TB
C[Client] --> CDN[CDN / Edge Cache<br/>< 1ms, hot data]
CDN -.miss.-> AppCache[App-level Cache<br/>< 5ms, in-process]
AppCache -.miss.-> Redis[Redis Cluster<br/>< 5ms, shared]
Redis -.miss.-> DB[(DB<br/>10-100ms)]
Note1[各層 1 桁ずつ<br/>レイテンシが違う]
style CDN fill:#e1ffe1
style AppCache fill:#e1ffe1
style Redis fill:#fff4e1
style DB fill:#ffe1e1
認証基盤の例
// L1: アプリ in-process(< 1ms)
const cached = inProcessCache.get(token);
if (cached && !cached.expired) return cached.payload;
// L2: Redis(< 5ms)
const fromRedis = await redis.get(`token:${token}`);
if (fromRedis) {
inProcessCache.set(token, fromRedis);
return fromRedis;
}
// L3: DB / 暗号検証(10-100ms)
const verified = verifyJWT(token);
await redis.setex(`token:${token}`, 300, verified);
inProcessCache.set(token, verified);
return verified;
hot data ほど上の層に。cache hit 率で性能が決まる。
Cache 階層の罠
| 罠 | 対策 |
|---|---|
| Stampede(expire で殺到) | jitter / lock / probabilistic refresh |
| Cache invalidation の漏れ | versioned key、TTL 強制 |
| In-process cache のメモリ膨張 | size limit + LRU |
| 依存先の同時障害 | stale-while-revalidate |
詳細は前作 DB 設計の軸 2026 ch08-09 を参照。
Backpressure ─ 自分を守る仕組み
「全員のリクエストを処理しきれない」状態に陥ったとき、処理能力を超える request を受けない のが Backpressure。
Naive な実装の問題
// ❌ 全リクエストを受けて queue に積む
async function handleRequest(req) {
queue.push(req); // 無限に詰める
// → メモリ枯渇 / latency 爆発
}
これは latency が無限に伸びる。利用者は timeout して retry、retry が殺到して状況が悪化する(retry storm)。
✅ Backpressure 付き
async function handleRequest(req) {
if (queue.size > MAX_QUEUE) {
return { status: 503, retry_after: backoff() }; // ← 早期に拒否
}
queue.push(req);
}
早期に 503 を返す ことで、利用者に「retry してね(時間を空けて)」と伝える。
Adaptive な Backpressure
ハードコードされた閾値より、動的に調整 する方が現代的:
Netflix’s Concurrency Limit Algorithm:
Latency が悪化したら自動的に concurrency limit を下げる。改善したら上げる。
TCP の congestion control と似たアイデアを application layer に持ってくる。
graph LR
T[トラフィック増]
T --> L[Latency 悪化検知]
L --> R[concurrency limit を下げる]
R --> S[503 を返す request 増]
S --> R2[Latency 回復]
R2 --> U[concurrency limit を戻す]
style L fill:#fff4e1
style R2 fill:#e1ffe1
N+1 問題と batch API
利用者が ループ内で API を呼ぶ と、共通基盤側で N+1 が起きる:
// ❌ 利用者のコード(N+1)
for (const userId of userIds) { // 1000 user
const profile = await profileService.get(userId); // 1000 req
}
共通基盤側が 1000 req を捌くことになる。Batch API を提供する ことで救える:
// ✅ Batch API
const profiles = await profileService.getBatch(userIds); // 1 req
サーバー側は 1 req で済み、内部的に DB に WHERE id IN (...) で 1 query。
graph TB
subgraph "N+1: ❌"
U1[Client] -->|1000 req| S1[Server]
S1 -->|1000 query| D1[(DB)]
end
subgraph "Batch: ✅"
U2[Client] -->|1 req| S2[Server]
S2 -->|1 query| D2[(DB)]
end
style D1 fill:#ffe1e1
style D2 fill:#e1ffe1
Batch API を提供しないと、利用者は N+1 を書く。提供する側の責任。
DataLoader パターン
Batch API の自動化として DataLoader(Facebook 発祥のパターン):
const loader = new DataLoader(async (userIds) => {
return profileService.getBatch(userIds);
});
// 利用者は普通に loader を呼ぶ
const profile1 = await loader.load(userId1);
const profile2 = await loader.load(userId2);
// → 同じ tick 内のリクエストが自動で batch される
GraphQL の文脈で広まったが、任意の batch 化に使える。
Streaming で latency を分散する
長時間の処理を stream で返す ことで、p99 を改善できる:
# ❌ 全結果を待ってから返す
GET /v1/large-export
HTTP/1.1 200 OK
[5 秒待ってから 100MB の JSON]
# ✅ Streaming で順次返す
GET /v1/large-export
HTTP/1.1 200 OK
Transfer-Encoding: chunked
[最初の 100KB を 50ms で返す、残りは順次]
利用者が 最初のデータを見るまでの時間(TTFB) が劇的に改善する。これは LLM API の typical な設計(token streaming)と同じ思想。
Async / Job 化
同期的に応答する必要がない 処理は async に逃がす:
// ❌ 同期で全部やる
POST /v1/reports
HTTP/1.1 200 OK
{ "report": "...100MB..." } // 30 秒
// ✅ Job として受け付ける
POST /v1/reports
HTTP/1.1 202 Accepted
{ "job_id": "j_xxx", "status_url": "/v1/jobs/j_xxx" }
// 利用者は polling or callback で結果取得
202 Accepted で受付、後で polling か webhook で結果通知。同期 latency を切り離す ことで p99 が劇的に改善。
性能予算のチェックリスト
graph TB
Q1[Latency 目標] --> Q2[Cache 階層]
Q2 --> Q3[Backpressure]
Q3 --> Q4[Batch API]
Q4 --> Q5[Async / Job]
Q5 --> OK[捌ける設計]
Q1 -->|未設定| FixSet[p50/p95/p99 の目標を決める]
Q2 -->|なし| FixCache[L1/L2/L3 構築]
Q3 -->|なし| FixBP[Adaptive limit]
Q4 -->|なし| FixBatch[batch endpoint 追加]
Q5 -->|同期のみ| FixAsync[長時間処理は 202]
style OK fill:#e1ffe1
この章の要点
- 共通基盤は 集約トラフィック を捌く立場、桁違いの req/s
- 性能予算:p50/p95/p99/p999 で計測、p99 が利用者に倍増して波及
- Cache 階層:CDN / In-process / Redis / DB の 4 層、各層 1 桁ずつ違う
- Backpressure:早期に 503 で守る、Adaptive concurrency limit
- N+1 対策:Batch API を提供、DataLoader パターン
- Streaming で TTFB 改善、Async / Job で同期 latency 切り離し
次章への問いかけ
性能で利用者を守れた。だが 悪意ある利用 や 横断的な情報漏洩 から守る必要もある。
次章で 非機能要件 (3) セキュリティ ── Zero Trust と Tenant 分離、Policy as Code。