Web APIのパフォーマンス最適化ガイド ― キャッシュ・DB・ネットワークの実践テクニック
レスポンスが遅いAPIは、ユーザー体験だけでなくインフラコストにも直結する。本記事では、キャッシュ戦略、データベースクエリの改善、ネットワークレイヤーの工夫、そして計測の仕方まで、実務でそのまま使える最適化手法を段階的にまとめる。
1. なぜAPIは遅くなるのか
API応答が遅くなる原因は、大きく分けて「サーバー内部処理の遅延」「データベースアクセスの遅延」「ネットワーク往復の遅延」の3つに分類できる。多くの現場では、これらが複合的に絡み合っているため、闇雲にコードを書き換えても効果が出ないことが多い。
たとえば、N+1クエリ問題によってデータベースへのラウンドトリップが増えているケースでは、いくらアプリケーションサーバーのCPUを増強しても改善は見込めない。逆に、外部APIへの同期呼び出しがボトルネックになっている場合は、DBのインデックスをどれだけ最適化しても意味がない。
「推測するな、計測せよ」というのはパフォーマンスチューニングにおける最も重要な原則である。
2. 計測なくして最適化なし
最適化に着手する前に、まず現状のボトルネックを可視化する必要がある。代表的な計測手法は以下の通り。
| 手法 | 用途 | ツール例 |
|---|---|---|
| APM(分散トレーシング) | リクエスト単位のレイテンシ内訳 | Datadog, New Relic, OpenTelemetry |
| スロークエリログ | DBクエリの実行時間分析 | MySQL slow query log, pg_stat_statements |
| 負荷試験 | スループット限界の把握 | k6, Locust, JMeter |
| プロファイラ | CPU/メモリのホットスポット特定 | pprof, py-spy |
特にAPMツールでリクエストごとの内訳を見ると、「DB待ちが80%を占めている」「外部API呼び出しが直列に3回走っている」といった具体的な事実が浮かび上がり、次に何をすべきかが明確になる。
3. データベースクエリの見直し
3.1 N+1問題の解消
ORMを利用している場合、リレーション先を都度取得する実装になっていないか確認する。以下は典型的なN+1の例と、その解消例である。
// 悪い例: ループ内でクエリが発行される
for (const post of posts) {
post.author = await db.users.findById(post.authorId);
}
// 良い例: 事前にまとめて取得する
const authorIds = posts.map(p => p.authorId);
const authors = await db.users.findByIds(authorIds);
const authorMap = new Map(authors.map(a => [a.id, a]));
posts.forEach(p => p.author = authorMap.get(p.authorId));
3.2 インデックス設計
WHERE句・JOIN句・ORDER BY句で頻繁に使われるカラムには複合インデックスを検討する。ただし、インデックスは書き込み性能とのトレードオフであるため、読み取り頻度と書き込み頻度のバランスを見て設計する必要がある。
3.3 ページネーションの実装
OFFSETを使ったページネーションは、ページが深くなるほどスキャン量が増え遅くなる。カーソルベース(キーセット)ページネーションへの切り替えを検討したい。
-- OFFSETベース(深いページで遅い)
SELECT * FROM items ORDER BY id LIMIT 20 OFFSET 100000;
-- カーソルベース(常に高速)
SELECT * FROM items WHERE id > :last_id ORDER BY id LIMIT 20;
4. キャッシュ戦略の設計
キャッシュは最も費用対効果の高い最適化手法の一つだが、設計を誤るとデータ不整合の温床にもなる。レイヤーごとに適切な戦略を選ぶことが重要である。
- CDNキャッシュ: 静的アセットや変化の少ないAPIレスポンスをエッジでキャッシュ
- アプリケーションキャッシュ: Redis/Memcachedで計算結果や集計値を保持
- クエリキャッシュ: 同一クエリの結果を短時間だけメモリに保持
- HTTPキャッシュ:
Cache-ControlやETagによるブラウザ・プロキシキャッシュ
キャッシュ導入時に必ず検討すべきなのが「無効化戦略」である。TTLベースの緩やかな失効に加え、書き込み発生時に明示的にキャッシュを削除する Write-through / Cache-aside パターンを組み合わせることで、鮮度と速度のバランスを取ることができる。
async function getUser(id) {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.users.findById(id);
await redis.set(`user:${id}`, JSON.stringify(user), "EX", 300);
return user;
}
5. ネットワークとペイロードの最適化
レスポンスサイズを削減することも即効性の高い施策である。以下のような対策が代表的だ。
- gzip / Brotli による圧縮の有効化
- 不要なフィールドを含まないレスポンス設計(GraphQLやフィールド選択パラメータの活用)
- HTTP/2・HTTP/3による多重化・ヘッダー圧縮の活用
- Keep-Aliveによるコネクション再利用
また、地理的に離れたユーザーへの応答速度を改善するには、リージョンをまたいだレプリカ配置やCDN経由のオリジンシールドが有効な選択肢となる。
6. 非同期化とバックグラウンド処理
ユーザーの応答を待たせる必要のない処理(メール送信、通知、集計処理など)は、リクエストの同期パスから切り離してキューに投入し、バックグラウンドワーカーで処理するのが定石である。
app.post("/orders", async (req, res) => {
const order = await createOrder(req.body);
await queue.enqueue("send-confirmation-email", { orderId: order.id });
res.status(201).json(order); // メール送信を待たずに即レスポンス
});
7. スケールする構成へ
単一サーバーでの最適化に限界を感じたら、水平スケーリングを検討する段階に入る。ロードバランサー配下に複数のアプリケーションインスタンスを配置し、DBは読み取りレプリカで負荷分散、書き込みはシャーディングを検討するといった構成が一般的である。
| ボトルネック | 対応策 |
|---|---|
| アプリケーションCPU | 水平スケール・オートスケーリング |
| DB読み取り負荷 | リードレプリカ・キャッシュ層の追加 |
| DB書き込み負荷 | シャーディング・非正規化 |
| ネットワーク帯域 | CDN・圧縮・エッジコンピューティング |
まとめ
パフォーマンス最適化は一度やって終わりではなく、計測 → 仮説 → 改善 → 再計測のサイクルを継続的に回すプロセスである。今回紹介したキャッシュ設計、クエリ改善、ネットワーク最適化、非同期化といった手法を、実際のボトルネックデータに基づいて優先順位をつけながら適用していくことが、最も効果的なアプローチとなる。