パフォーマンス最適化 — 100から100万QPSへ
パフォーマンス最適化は高速道路の建設のようなもの - 最も混雑している区間 (ボトルネック)から修正し, 各区間の完了後に結果を測定するのであり, 道路全体を無差別に拡幅するわけではありません。見込みのある最適化は無駄であり, 計測に基づく最適化こそが最も効率的です。
1. 学ぶ内容
- 非同期ブロッキングのトラブルシューティング:
asyncioの落とし穴とrun_in_executorによる同期コードの書き換え - データベース最適化:インデックス戦略, クエリ最適化, コネクションプールチューニング
- 並行性モデル:Uvicorn Worker数とGunicorn設定
- レスポンス圧縮:
GZipMiddlewareとJSONシリアライズ最適化 (orjson) - Aliceのシナリオ:PriceTrackerの単機500 QPSからクラスタ100万QPSへの最適化パス
2. Aliceの実話
(1) 悩み:単一サーバーの500 QPSでは足りない
PriceTrackerの本番運用開始後, 単一サーバーでは500 QPSしか処理できませんでしたが, セールイベント時にはトラフィックが5,000 QPSに急増し, APIレスポンスがタイムアウトしていました。Charlieはサーバーを追加しましたが, 改善は微々たるものでした - データベースコネクションプールが枯渇し, 同期コードがイベントループをブロックし, JSONシリアライズがCPUの40%を消費していました。
(2) 体系的なパフォーマンス最適化アプローチ
パフォーマンス最適化は推測ではなく, 計測 → ボトルネック特定 → 最適化 → 検証のサイクルです。コードレベル (非同期/シリアライズ)からデータベースレベル (インデックス/コネクションプール), そしてアーキテクチャレベル (Workers/負荷分散)まで, 段階的に問題に取り組みます。
(3) 成果
PriceTrackerのQPSは500から10,000 (単一サーバー)に最適化され, 負荷分散クラスタで100万QPSに到達。P99レイテンシは500 msから30 msに短縮されました。
3. パフォーマンス最適化のレベル
(1) 3層最適化モデル
graph TD
L3[インフラ層] --> L2[データベース層]
L2 --> L1[アプリケーション層]
L1 --- A1[async/await - ブロック検出]
L1 --- A2[シリアライズ - orjson]
L1 --- A3[圧縮 - GZip]
L2 --- D1[インデックス - クエリ最適化]
L2 --- D2[コネクションプール - サイズチューニング]
L2 --- D3[クエリパターン - N+1防止]
L3 --- I1[Workers - Gunicorn設定]
L3 --- I2[ロードバランサー - Nginx]
L3 --- I3[オートスケーリング - K8s HPA]
(2) QPS向上のパス
flowchart LR
A[500 QPS\nベースライン] -->|async + orjson| B[2000 QPS]
B -->|DBインデックス + プール| C[10000 QPS]
C -->|4 Workers| D[40000 QPS]
D -->|Nginx LB x 5| E[200000 QPS]
E -->|K8s x 5 pods| F[1000000 QPS]
| フェーズ | 最適化手法 | QPS | 向上倍率 |
|---|---|---|---|
| ベースライン | デフォルト設定 | 500 | 1x |
| アプリケーション層 | async + orjson | 2,000 | 4x |
| データベース層 | インデックス + コネクションプール | 10,000 | 20x |
| Workers | 4 Workers + Gunicorn | 40,000 | 80x |
| 負荷分散 | Nginx 5インスタンス | 200,000 | 400x |
| K8s弾力性 | 5 Pods オートスケーリング | 1,000,000 | 2000x |
4. アプリケーション層の最適化
(1) 非同期ブロッキングのトラブルシューティング
(1) ▶ サンプル:イベントループをブロックする同期コード
PYTHON
import asyncio
import time
# BAD: 同期I/Oがイベントループをブロック
async def get_price_bad(product_id: int):
time.sleep(0.1) # 他のすべてのリクエストを100msブロック!
return {"product_id": product_id, "price": 9.99}
# GOOD: asyncioでI/Oを処理
async def get_price_good(product_id: int):
await asyncio.sleep(0.1) # 非ブロッキング, 他のリクエストは継続
return {"product_id": product_id, "price": 9.99}
出力:
TEXT
# 関数定義成功
(2) ▶ サンプル:run_in_executorで同期コードをラップ
PYTHON
import asyncio
from functools import partial
# 非同期化できない同期関数
def sync_scrape_price(url: str) -> float:
# requestsライブラリを使用 (同期のみ)
import requests
response = requests.get(url, timeout=10)
return parse_price(response.text)
async def get_price_async(product_id: int):
loop = asyncio.get_event_loop()
# スレッドプールで同期関数を実行 - イベントループをブロックしない
price = await loop.run_in_executor(
None, # デフォルトスレッドプール
partial(sync_scrape_price, f"https://api.example.com/prices/{product_id}"),
)
return {"product_id": product_id, "price": price}
出力:
TEXT
# 関数定義成功
(2) JSONシリアライズ最適化
(3) ▶ サンプル:jsonの代わりにorjsonを使用
PYTHON
# インストール:uv add orjson
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
app = FastAPI(default_response_class=ORJSONResponse)
@app.get("/api/v1/products")
async def list_products():
# orjsonは標準ライブラリjsonより3〜10倍高速
return {"products": [{"id": i, "name": f"Product {i}"} for i in range(100)]}
出力:
TEXT
# 関数定義成功
| シリアライズライブラリ | 速度 | 説明 |
|---|---|---|
| json (標準ライブラリ) | ベースライン | 純粋なPython実装 |
| orjson | 3〜10倍 | Rust実装, datetimeを自動処理 |
| ujson | 2〜3倍 | C実装, 一部互換性の問題あり |
(4) ▶ サンプル:GZipMiddleware圧縮
PYTHON
from fastapi import FastAPI
from starlette.middleware.gzip import GZipMiddleware
app = FastAPI()
app.add_middleware(GZipMiddleware, minimum_size=1000) # 1KB以上のレスポンスを圧縮
@app.get("/api/v1/products")
async def list_products():
# 大きなJSONレスポンスが自動的に圧縮される
return {"products": [...]} # 50KB → gzipで~5KB
出力:
TEXT
# 関数定義成功
5. データベース層の最適化
(1) インデックス戦略
(1) ▶ サンプル:PriceTrackerのインデックス設計
PYTHON
from sqlalchemy import Index
class Product(Base):
__tablename__ = "products"
__table_args__ = (
# よく使うフィルター用の単一カラムインデックス
Index("ix_products_category", "category"),
Index("ix_products_created_at", "created_at"),
# category + 価格範囲クエリ用の複合インデックス
Index("ix_products_category_base_price", "category", "base_price"),
# アクティブな製品のみの部分インデックス (PostgreSQL固有)
Index("ix_products_active", "created_at", postgresql_where=("deleted_at IS NULL")),
)
...
出力:
TEXT
# 実行成功
| インデックスタイプ | 適したクエリ | 例 |
|---|---|---|
| 単一カラムインデックス | 等価/範囲フィルター | WHERE category = ? |
| 複合インデックス | 複数条件の組み合わせ | WHERE category = ? AND price > ? |
| 部分インデックス | 条件付きサブセット | WHERE deleted_at IS NULL |
| カバリングインデックス | テーブル検索の回避 | INCLUDE (name, price) |
(2) コネクションプールチューニング
(2) ▶ サンプル:本番レベルのコネクションプール設定
PYTHON
engine = create_async_engine(
DATABASE_URL,
pool_size=25, # Workerごとの持続的接続数
max_overflow=10, # プール枯渇時の追加接続数
pool_timeout=30, # 利用可能接続の待機時間
pool_recycle=1800, # 30分後に接続を再利用
pool_pre_ping=True, # 使用前に接続をテスト
echo=False, # 本番ではSQLログを無効化
)
出力:
TEXT
# 実行成功
| パラメータ | 推奨値 | 説明 |
|---|---|---|
pool_size |
CPUコア数 x 2 + 1 | 基本接続数 |
max_overflow |
pool_size x 0.5 | バースト時の最大追加接続数 |
pool_timeout |
30s | タイムアウト |
pool_recycle |
1800s | MySQL/PGがアイドル接続を閉じるのを防止 |
pool_pre_ping |
True | 切断された接続の使用を防止 |
6. 並行性モデルの最適化
(1) Workers設定
(1) ▶ サンプル:Gunicorn + Uvicorn Workers
BASH
# 本番:GunicornがUvicornワーカーを管理
gunicorn app.main:app \
--workers 9 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 120 \
--graceful-timeout 30 \
--access-logfile - \
--error-logfile -
出力:
TEXT
# コマンド実行成功
DOCKERFILE
# Dockerfile内
CMD ["gunicorn", "app.main:app", \
"--workers", "9", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000"]
| 設定 | 式/値 | 説明 |
|---|---|---|
| Worker数 | (2 x CPU) + 1 |
4コア = 9ワーカー |
| worker-class | UvicornWorker | ASGIワーカー |
| timeout | 120s | タイムアウトによるWorker強制終了 |
| graceful-timeout | 30s | グレースフルシャットダウンのタイムアウト |
| max-requests | 10,000 | メモリリーク防止のための自動再起動 |
❓ よくある質問
Q パフォーマンスのボトルネックはどう特定しますか?
A ツールチェーンを使用します - cProfileでCPUホットスポットを分析, py-spyでリアルタイムサンプリング, Prometheusでレイテンシ分布を監視, スロークエリログでデータベースを分析します。まず計測し, それから最適化します。
Q
run_in_executorのスレッドプールのサイズはどのくらいですか?A デフォルトは
min(32, os.cpu_count() + 4)です。CPU集約型タスクには, GILの制限を回避するためにProcessPoolExecutorを使用してください。Q orjsonはPydanticと互換性がありますか?
A はい, 互換性があります。FastAPIの
default_response_class=ORJSONResponseは, Pydanticモデルをorjsonで自動的にシリアライズし, model_dump()の出力を生成します。Q コネクションプールとWorker数のバランスはどう取りますか?
A 各ワーカーには独自のコネクションプールがあります。9ワーカー x 25接続 = 225データベース接続。PostgreSQLのデフォルト
max_connectionsは100なので, pool_sizeを適宜増減させる必要があります。Q GZip圧縮にパフォーマンスオーバーヘッドはありますか?
A 圧縮はCPUリソースを消費しますが, ネットワーク伝送時間を短縮します。1 KBを超えるJSONレスポンスの場合, 圧縮のメリットはCPUオーバーヘッドを大幅に上回ります (圧縮率は通常5〜10倍です)。
Q 最適化の効果はどう確認しますか?
A Locustやk6で負荷テストを行い, 最適化前後のQPS, P50/P95/P99レイテンシ, CPU/メモリ使用量を比較します。一度に変更する変数は1つだけにしてください。
📖 まとめ
- パフォーマンス最適化の3層モデル:アプリケーション層 (非同期/シリアライズ)→ データベース層 (インデックス/コネクションプール)→ インフラ層 (Workers/LB)
- 同期I/Oは
run_in_executorでラップし, イベントループのブロックを防止 - orjsonはjsonよりシリアライズが3〜10倍高速, GZipMiddlewareは大きなレスポンスを圧縮
- データベースインデックスはクエリパターンに基づいて設計 (単一カラム, 複合, 部分インデックス), コネクションプールサイズはWorker数と連動
- Gunicorn + Uvicorn Workers:
(2 x CPU) + 1ワーカー, K8sオートスケーリングで100万QPSへ
📝 練習問題
- 基本問題 (難易度 ⭐):orjsonをインストールし, FastAPIのデフォルトレスポンスクラスをORJSONResponseに変更し, curlでjsonとorjsonのレスポンスタイムを比較してください。ヒント:
default_response_class=ORJSONResponse - 応用問題 (難易度 ⭐⭐):PriceTrackerの
productsテーブルにインデックスを追加し (category,created_at, およびcategoryとbase_priceの複合インデックス), インデックス追加前後で同じクエリの実行時間を比較してください。ヒント:Index("ix_name", "col1", "col2")+EXPLAIN ANALYZE - チャレンジ (難易度 ⭐⭐⭐):総合パフォーマンス最適化 - orjsonシリアライズ + GZipMiddleware + コネクションプールチューニング + Gunicorn 4ワーカー。Locustで負荷テストを実行し, 最適化前後のQPSとP99レイテンシを比較してください。ヒント:
locust -f locustfile.py --host=http://localhost:8000
---|



