月末月初のサービスダウンからの脱却──SQLとコードの地道な改善で実現した高速化
TOKIUMで経理業務に関するソフトウェアの開発をしています、岡本です。
ここ数ヶ月は経理AIエージェントを開発していますが、その前は経理SaaS(経費精算/請求書受領/電子帳簿保存など)の開発業務を担当していました。
2024年の夏秋にかけて組織全体で力を入れていた、SaaSのWebアプリケーションのパフォーマンスチューニングに関する取り組みについて、一部ではありますがご紹介をしたいと思います。
対象システムの概要
経費精算/請求書受領/電子帳簿保存のバックエンドはRails、フロントエンドはReactで書かれています。経費精算のソースコードのFirstCommitが2015年にされているので10年の歴史を持つシステムです。
経費精算、請求書受領、電子帳簿保存はデータリソースとして共通のRDB(PostgreSQL)を参照しています。
抱えていた課題
月末月初は経費の締め処理、月次決算処理、承認処理、請求書の受領処理が集中し、アクセス負荷が高まる傾向にあります。
事業の成長に伴って契約社数およびユーザー数は毎月毎月増え続けていました。それらの増加に伴って扱うデータ量・アクセス量も増加、それに耐えきれずにサービスダウンしてしまう現象が発生していました。
スケールアップ、スケールアウトなどインフラストラクチャの増強による対策もなされました。
ただAPMツールが示すログ・トレースからはシステムのボトルネックがDBのI/Oにあることは明白でした。よって、まずはスロークエリを生み出しているコードがどこにあるかを調査・明文化するところから始めました。
課題に対するアプローチ
- 被疑箇所の洗い出し
- ログ・トレースを見る
- 実行計画を見る
- コードの修正、テーブル構造の見直し
- 本番環境と同等のテスト環境での検証
当時はDevinやClaude Codeのようなコーディングエージェントがまだ台頭していませんでした。ChatGPTやGitHub Copilotと対話をしながら実行するSQLを試行錯誤し、コードを修正していました。また経験豊富な社内のエンジニアの皆さんのレビューを頂きながら、改善を続けていました。
改善にあたり、『SQL実践入門──高速でわかりやすいクエリの書き方』(ミック著、技術評論社、2015年)をよく参照していました。
一例:とあるAPIのパフォーマンス改善
以下で示す論理名・物理名は実際に扱っているものとは異なります。
問題の発見
タイムスタンプ検証というAPIエンドポイントの平均レスポンスタイムが11秒程度まで増大していることを発見しました。特に以下の特徴が見られました。
- 夜間や早朝など、特定時間帯にAPIリクエストが集中
- 月末月初の業務集中期間に負荷が高まる傾向
パフォーマンス分析
APMツールを使用して詳細な分析を実施したところ、以下のボトルネックを特定しました。
1. データベースクエリの問題
Active Record のインスタンス生成
- 平均1.7秒、全体の24%の実行時間を占める
- 大量のレコードのインスタンス化に時間がかかっていた
N+1クエリの発生
-- 頻繁に実行されていたクエリ例
SELECT COUNT(*) FROM verifications
WHERE export_id = ?
- 平均36.4ms × 多数回の実行
- 全トレースの98.9%で実行されていた
2. 実行計画の分析結果
-- パラレルシーケンシャルスキャン発生
Parallel Seq Scan (cost=0.00..86936.50 rows=15045)
Filter: (export_id = ?)
Rows Removed by Filter: 392384
Execution Time: 493.831 ms
適切なインデックスが不足しており、フルテーブルスキャンが発生していました。
改善アプローチ
1. N+1クエリの解消
シリアライザー内で個別にカウントを取得していた処理を、一度のクエリで必要な情報をすべて取得するように変更
-- 改善後:JOINとGROUP BYで一度に取得
SELECT
exports.*,
COUNT(verifications.id) AS verification_count,
SUM(CASE WHEN verifications.completed_at IS NOT NULL
THEN 1 ELSE 0 END) AS completed_count
FROM exports
LEFT OUTER JOIN verifications
ON verifications.export_id = exports.id
WHERE exports.user_id = ?
AND exports.group_id = ?
GROUP BY exports.id
ORDER BY exports.created_at DESC;
2. インデックスの追加
WHERE句で頻繁に使用される外部キーにインデックスを追加
-
verifications.export_id
にインデックスを付与 - これによりIndex Scanの利用を促進
3. JOINの順序最適化(駆動表の選択)
駆動表(JOINの起点となるテーブル)を適切に選択することで大幅な改善
- 変更前:大きいテーブルを駆動表として使用(500ms)
- 変更後:小さいテーブルを駆動表として使用(100ms)
- 駆動表のレコード数が少ないほど、JOINの処理効率が向上
結果
パフォーマンス改善効果
- レスポンスタイム: 11秒 → 0.6秒(約95%削減)
- データベース負荷: クエリ実行回数を大幅に削減
- ユーザー体験: モーダル表示時の待機時間が大幅に短縮
実行計画の改善
-- 改善後:インデックススキャンを利用
Index Scan using idx_verifications_export_id
(cost=0.43..8.45 rows=1)
Execution Time: 0.123 ms
得られた教訓
1. まずN+1クエリの解消
- APMツールでクエリの実行回数を監視
- 開発環境でのクエリログの確認
2. インデックスの重要性
- WHERE句で使用される外部キーには基本的にインデックスを付与
- 過度なインデックスは書き込み性能を低下させるため注意
- 場合によってPartial IndexやGIN Indexなどの使用を検討
- 実行計画を確認し、効果を検証することが重要
3. JOINの重要性
- 駆動表(JOINの起点)は小さいテーブルを選ぶのが基本
- PostgreSQLのプランナーは必ずしも最適な駆動表を選択しないことがある
- 明示的なJOIN順序の指定やヒント句の使用が有効な場合もある
- 実行計画で駆動表の選択を確認することが重要
これらすべてが重要な要素となります。
得られた結果
こういった要領で、あらゆる機能のパフォーマンスを改善していきました。難なく終わらせられるものもあれば、改善に数ヶ月苦しむものも存在しました。
私が所属するチームが管轄していた主要機能に関しては、99パーセンタイル帯で最大10秒要していたようなエンドポイントが、数百ms~1秒台で落ち着くなどしてサービス全体の性能改善に繋げることができました。
この取り組みを通じてエンジニア一人一人のパフォーマンスに対する意識が高くなったと感じます。
毎日Datadogのダッシュボードをチェックする習慣ができました。怪しいクエリがあれば即座に対応できるようにしています。
実装やコードレビューの場面でも怪しいコードやクエリがあれば、サーバに流れるログを必ずチェックして、実行計画を確認する。リリースをしたら監視する。
手慣れのエンジニアにとっては当たり前の作業かもしれませんが、この当たり前を次世代にも継承していく所存です。
最後に
開発組織全体で取り組んだ今回の改善において、私が携わったクエリチューニングがサービスの性能を劇的に改善する一助となったのは、大きな学びとなりました。
現在TOKIUMではSaaSと並行して経理業務に特化したAIエージェントをMastraで開発しております。私も開発をしていますが、今回紹介した内容を含むWebアプリケーション開発の知見がすごく生かされるなぁと日々感じています。
Discussion