前回の記事で全文検索(FullText Search)を使用するクエリの構造を変更してクエリの実行時間を30倍短縮しました。
しかしレビュー件数とユーザーが多く選んだ上位タグなどの追加情報を表示しなければならないという要件を反映するためのクエリを作成したところ、実行時間が少し増加するというデメリットがありました。
そして何よりもこのようなビジネス的な要件がクエリの中に溶け込んでいるため、DBアクセス技術を変更したりビジネスロジックを修正する必要が生じたとき、クエリを直接修正しなければならないため、メンテナンスの観点から柔軟性が低いという問題点が存在します。
しかしその前に解決すべき問題を明確に定義する必要がありました。
どのような問題を解決したいのか?#
改善すべき事項は単純にクエリの実行時間を短縮する部分だけではありません。クエリの実行時間の改善はAPIの性能を向上させるツールの一つに過ぎません。
もう少し大きく見て問題を定義すると、既存のAPIはクエリの実行時間によりPostmanでリクエストした際の応答まで大よそ6.85秒かかっていましたが、結局APIの性能が出ていないことを問題として定義できます。
そしてここから目標はAPIの性能が出ていないことを解決することです。
まずビジネス要件を定義します#
/product?search="xxx"検索をリクエストします。またpageとsizeパラメーターでページングを適用します。- 各productに該当するreviewの件数を表示します。
- 各productに対する上位3つのタグを表示します。ユーザーがレビュー作成時に自由に選んだタグの中で最も多く選ばれた3つを表示します。
性能目標の設定#
APIの性能を改善するには、まず**目標が必要です。**目標なしにテストを進めると結果が良いのか悪いのか判断する基準がないからです。
データ規模からサービス規模を推定する#
現在のテスト環境に保存されているデータは以下のとおりです。
- Product:150万件
- Review:1,000万件
- ProductDietTag:2,000万件
Productはシードデータとユーザー登録が混在していると仮定すると、DAUと直接比例しません。一方Reviewはユーザーが直接作成するデータのため、逆にDAUを推定できます。
- 運用期間1年と仮定:10,000,000件 / 365日 ≈ 日平均27,000件のレビュー生成
- 食事記録アプリの特性上、DAUのうちレビューを作成する比率は高い方(記録行為自体がコア機能)
- 1人あたりの日平均レビュー数:約0.6〜1.35件(アクティブユーザー基準)
- DAU ≈ 27,000 / 0.6 〜 27,000 / 1.35 ≈ 約20,000〜45,000人
保守的にDAU 20,000人を基準として設定します。
トラフィックの推定#
食事記録サービスの特性上、検索APIは1日15〜25回/人呼び出されます。(各食事記録時に製品検索+探索5〜8回と仮定)
- 日次総呼び出し:20,000人 × 20回 = 400,000 req/day
- ピーク時間帯(昼11:30〜13:30、夜18:00〜20:00):全体の50%以上が4時間に集中
- ピークRPS:約15 RPS
- 瞬間バースト(ピークの3〜5倍):約45〜75 RPS
応答時間の目標#
| 指標 | 目標 | 根拠 |
|---|---|---|
| 平均応答時間 | < 200ms | ユーザーが遅延を感じないレベル |
| Error Rate | < 0.1% | 検索失敗時にユーザー離脱が高い |
テスト負荷の設定#
今回のテストではVUser 300人を使用します。これは現実的なピーク(15 RPS)よりも高い負荷で、システムの限界点を把握するためのストレステストの性格です。目標負荷でのパフォーマンスではなく、極限状況においてシステムがどこまで耐えられるかを確認することが目的です。
どのようにAPIの性能を比較するか?#
上記の要件を満たすAPIを作成するために、従来はDataGripを使用してクエリの実行時間を分析していました。しかしプロセスの実装方式やテーブル構造が変更される場合、データ構造も一緒に変わるため、単純に数百万件のデータでクエリの実行時間を比較することは正確な基準になりにくいです。
したがってより正確な性能測定のための基準が必要になりました。
APIの性能を正確にテストするには、Postmanで単一の応答時間を測定するだけでは不十分で、より多くのリクエストが必要でした。また検索機能をテストする際に同じキーワードだけでは限界がありました。MySQL InnoDBのキャッシュ機能を考慮すると様々なデータへのアクセスが必要だったからです。このような理由からnGrinderを使って様々なデータにアクセスするトラフィックを発生させ、PrometheusとGrafanaを活用したモニタリングを通じてAPIの性能を評価することにしました。
テスト構成#
1. DBのスキーマ構成#
前述のクエリ構造変更の記事から持ってきたDDLをそのまま使用します。
-- 150万件のデータ
-- n-gramアルゴリズムを適用した全文検索インデックスを使用
CREATE TABLE product (
product_id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_name VARCHAR(255) NOT NULL,
product_corp VARCHAR(255),
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FULLTEXT INDEX fulltext_idx (product_name, product_corp) WITH PARSER ngram
);-- 1,000万件のデータ
CREATE TABLE review (
review_id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id BIGINT NOT NULL,
content VARCHAR(255),
rating TINYINT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX idx_product_id ON review (product_id);-- 2,000万件のデータ
CREATE TABLE product_diet_tag (
product_diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id BIGINT,
diet_tag_id BIGINT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX idx_product_id ON product_diet_tag (product_id);-- 10件のデータ
CREATE TABLE diet_tag (
diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT,
diet_tag_name VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
現在MySQL DBには:
- productテーブル 150万 rows
- reviewテーブル 1,000万 rows
- product_diet_tagテーブル 2,000万 rows
- diet_tagテーブル 10件の rows
が保存されています。
2. モニタリングとnGrinderのためのクラウド構築#

最適なリソースを割り当てるためにサーバーはそれぞれのインスタンスに配置し、PrometheusとGrafanaのみを同じインスタンスに配置しました。
| インスタンス | スペック |
|---|---|
| Targetアプリケーションサーバー | vCPU 2EA、Memory 8GB |
| Target MySQL | vCPU 2EA、Memory 8GB |
| モニタリング | vCPU 2EA、Memory 8GB |
| nGrinder Controller | vCPU 2EA、Memory 8GB |
| nGrinder Agent | vCPU 2EA、Memory 8GB |
3. テストシナリオ#
負荷テストは5分間実施し、仮想ユーザーは最大300人で以下のイメージのように30秒ごとに段階的に増加させました。

API呼び出しシナリオでは、検索キーワード、size、pageの値をランダムな文字列や数字で様々に適用しました。
1回目のテスト:全文検索(FullText Search)のみを使用したテスト#
要件をすべて満たすAPIをテストする前に、全文検索機能だけを単独で使用したときにどの程度のAPIパフォーマンスが出るか気になったのでテストを実施してみました。
以下のコードはAPIテストに使用されたリポジトリクラスです。1回のAPI呼び出しでページングのためにクエリは2種類使用され、今回のテストではproductテーブル以外の他のテーブルは触れていません。
@Repository
@RequiredArgsConstructor
@Transactional
public class JdbcTemplateProductSearchRepository {
private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
private final DataClassRowMapper<ProductSearchResponse> beanPropertyRowMapper = new DataClassRowMapper<>(ProductSearchResponse.class);
public Page<ProductSearchResponse> findFullTextSearch(String keyword, Pageable pageable) {
Integer total = getTotalCount(keyword);
// OFFSETとLIMITを適用しました。
String sql = "SELECT " +
"p.product_id, p.product_name, p.product_corp " +
"FROM product p " +
"WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)" +
"LIMIT :offset, :limit";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("keyword", keyword)
.addValue("offset", pageable.getOffset())
.addValue("limit", pageable.getPageSize());
List<ProductSearchResponse> queried = namedParameterJdbcTemplate.query(sql, parameters, beanPropertyRowMapper);
return new PageImpl<>(queried, pageable, total);
}
// ページング処理のために全件数を照会するCOUNT(*)クエリ。
private Integer getTotalCount(String keyword) {
String sql = "SELECT COUNT(*) " +
"FROM product p " +
"WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("keyword", keyword);
return namedParameterJdbcTemplate.queryForObject(sql, parameters, Integer.class);
}
}テスト結果#


テストの結果は非常に深刻でした。
平均TPS(Transaction Per Second)が2.1となり、応答時間は徐々に増加して30秒が続きました。またエラー率が51.3%(958件中491件失敗)となりました。

エラーの原因はアプリケーションのログを確認したところconnection timeoutエラーが発生していました。これはHikariCPのコネクションプールのすべてのコネクションが使用中の状況で、新しいコネクションを取得するための待機時間がHikariCPのデフォルト設定値30秒を超えたためです。


NCPが提供するDBインスタンスモニタリングを通じて問題を分析した結果、CPU使用量が最大値に達しており、これが性能のボトルネック地点であることが確認できました。
目標対比分析: 目標である平均応答時間 < 200ms、Error Rate < 0.1%と比べて圧倒的に低い性能です。VUser 300人という高い負荷の下でMySQLの全文検索がCPUを100%占有してTPSが2.1しか出ませんでした。現実的なピーク(15 RPS)でも安定したサービスが難しいと判断されます。
番外テスト#
APIの実際の性能を測定するためにHikariCPのconnection_timeoutを余裕を持って300秒に変更した後、コネクション取得タイムアウトエラーが発生しないテストを実施しました。


APIの応答はすべて成功しましたが、平均TPSが1.9、平均応答時間は大よそ1.67分を維持、最大1.79分となりました。
目標対比分析: エラーはなくなりましたが平均応答時間が約1分です。目標200msの約300倍に達するため、タイムアウトの緩和では根本的な問題を解決できません。
そして次の番外テストとして、DBインスタンスのスペックを段階的にScale Upしてみました。
| 段階 | スペック |
|---|---|
| 既存 | vCPU 2EA、Memory 8GB |
| 1回目Scale Up | vCPU 8EA、Memory 32GB |
| 2回目Scale Up | vCPU 32EA、Memory 128GB |



テスト結果、2回目のScale Upスペック(vCPU 32EA、Memory 128GB)でテストを実施しても平均TPS 10.5、平均応答時間は大よそ15〜20秒かかりました。ただしCPU使用量は30%まで減少しました。
目標対比分析: 16倍Scale Up(vCPU 2→32)でもTPS 10.5、応答時間15秒です。目標ピークRPS 15にも満たず、応答時間は目標の75倍です。したがってMySQL全文検索だけではこの規模のデータで目標性能の達成が難しいと判断しました。ただしElasticsearchに移行する前に、実際のサービス要件をすべて含めたMySQLのベースラインも一度測定しておきます。
(Grafanaダッシュボードで途中に値が欠損する現象が続けて発見されました。これについての記事はGrafanaダッシュボードの欠損(断絶?)現象で取り上げています。)
2回目のテスト:すべての要件を満たしたテスト#
Elasticsearchに切り替えたときの改善幅を比較するために、前回の記事で使用したクエリを適用して、すべての要件を満たすMySQLのベースラインテストをもう一度実施しました。
SELECT p.product_id,
p.product_name,
p.product_corp,
(SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count,
(SELECT GROUP_CONCAT(diet_tag_name)
FROM (SELECT diet_tag_name
FROM product_diet_tag pdt
WHERE pdt.product_id = p.product_id
GROUP BY diet_tag_name
ORDER BY COUNT(diet_tag_name) DESC
LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names
FROM product p
WHERE MATCH(p.product_name, p.product_corp) AGAINST(:keyword)
LIMIT :offset, :limit;

予想通りテスト結果は惨憺たるもので、全文検索を単独で使用した場合と比較しても大きく変わりません。
なぜこれほど低い性能が出るのか?#
これは全文検索インデックスの特性を考慮する必要があります。
全文検索には既存のMySQL InnoDBストレージエンジンが提供する一般的な用途のB-treeインデックスを使用できません。
全文検索(Full Text search)インデックスは文書全体を分析・検索するための逆インデックス構造のインデックスアルゴリズムです。
そしてインデックス技法に応じて大きく**「単語の語根分析」と「n-gram分析」**アルゴリズムに分けられます。
**「単語の語根分析」**は英語のように単語の変形がある場合に、その単語の根である名詞または語根を見つけてインデックスを作成します。
しかし韓国語、中国語、日本語はこのような単語の変形がほとんどないため、本文を一定の長さで切り取ってトークンとしてインデックスを作成する**「n-gram分析」**を活用すると精度と効率性を向上させることができます。
そしてproductテーブルにはn-gram分析を活用した全文検索インデックスが作成されています。
しかしこのような方式にも欠点があります。
MySQLサーバーは全文検索クエリが来ると、インデックス作成時と同様に検索語をトークンサイズに合わせて切り取ります。そして切り取られたトークンに対して一致する単語の件数、頻度などを確認して一致率を計算します。
そして各トークンの結果に対して比較演算が実行され、この過程で重み付けの計算とソートが行われます。
したがって検索語が長いほどより多くのトークンが生成され、各トークンの結果を集めて比較演算を実行するため、CPUにかかる負荷が大きくなります。
また、実行計画を分析してみるとOFFSETが2000、LIMITが10の場合に2,010件のrowを読んだことが示されます。
SELECT p.product_id, p.product_name, p.product_corp
FROM product p
WHERE MATCH (p.product_name, p.product_corp) AGAINST ('鶏むね肉')
LIMIT 2000, 10;
しかし実際に内部的に計算される行の数は単純にOFFSETとLIMITで指定した件数だけではありません。
MySQLは全文検索でマッチングされるすべてのrowsに対して重みを計算してソートした後、ソート結果から上位2,000番目から2,010番目までを返します。
したがってページングを最適化するための方法であるNo Offset、カバリングインデックス、Total Count最適化のような技法を使用することも難しい状況でした。
実際にOFFSETを0に設定した場合と比較してみましたが性能上の有意差はなく、ページングのためのCOUNT(*)クエリを除去したときも僅かな性能改善しかありませんでした。(単純に一つのトランザクションで2つのクエリが発行されていたものを1つのクエリだけ発行するよう変えたので性能が大よそ2倍程度良くなるという非常に当然の現象…)
- TPS:2.1 -> 3.7
- エラー率:51.3% -> 28.6%(1,372件中393件失敗)
Elasticsearchエンジンの導入#
従来はElasticsearchの導入をコスト負担が大きいというデメリットのために選択せず、MySQLが提供する全文検索を選択しました。
しかし全文検索を活用した場合、100万件を超えるデータで性能が十分に出ないという致命的なデメリットが発見されました。
またアプリケーションでユーザーが呼び出すAPIのパターンを考慮してみたとき、食品検索APIはユーザーが非常に頻繁に呼び出すAPIでサービスにおいてコアな役割を担っていると判断しました。したがってコストを投資してでも性能を引き上げる必要が生じました。
前回のテストでDBエンジンをScale Upしましたが、それでもTPSは平均10程度、応答時間は15秒に近いままでした。限られたコストの中ではScale UpよりもElasticsearchの導入の方がより効果的だと判断しました。
当初は外部ストレージを活用したキャッシュも検討しましたが、キャッシュは検索条件が多様すぎて適用が難しく、データの特性と使用パターンの分析を先行しなければ効果的な部分キャッシングが可能だと判断したため選択しませんでした。
ElasticsearchエンジンはGCPが提供するElastic Cloudサービスを利用#


- インスタンススペック:vCPU 5個、Memory 4GB
- KIBANAのための1GB メモリ無料提供
- 総コスト:1時間 0.3321$ -> 0.3321 × 24 × 30 = 239$ = 345,594ウォン(レート1,446ウォン)
現在プロジェクトのクラウドサーバーを構成したNCPもSearch Engine Serviceというサービスを提供していますが、なぜかクラスター接続ができない問題が続けて発生しました。
NCPはSearch Engine Serviceクラスターを構成するのに最低4台のインスタンスが必要で、vCPU 2EA、Memory 8GBのインスタンスを4台使用するならば価格はGCPのElastic Cloudと似ていますが、性能はより優れていると予想されます。(ただしUIとクラスター構成の利便性はElastic Cloudが圧倒的に優れています。また、新規加入すると40万ウォンのクレジットも付与されます。)
直接インスタンスにElasticsearchを構築する方法も検討しましたが、初めて使う技術について何も知らない状態でパイプラインを構成するのに時間とコストがかかりそうだと感じました。
したがって優先的にElasticsearchエンジンを使用するとどの程度APIの性能が出るかを確認することに重点を置いて、素早く構成できるクラウドサービスを選択しました。
Elasticsearchエンジンを使用した3回目のテスト#


上記のテスト結果は、レビュー件数と上位タグ表示のような要件なしにproductへの検索(GETリクエスト)のみを実施したテスト結果です。
テスト結果を見ると、MySQLの全文検索を使用した場合と比較して(connection timeoutエラーを除去した場合):
- 平均TPS: 1.9 -> 192.5(約100倍向上)
- 応答時間: 大よそ1.67分 -> 800ms(800ms/100,200ms = 1/125に短縮)
目標対比分析: MySQL対比TPS 100倍、応答時間1/100レベルに劇的に改善されました。VUser 300人の負荷において平均926msのため、現実的なピーク(15 RPS)では平均200ms以内という目標達成が十分に可能なレベルです。
クエリにあった要件をビジネスレイヤーに移動#
前回の記事ではクエリの中にすべての要件が入っていました。
要件は以下のとおりです。
- 各productに該当するreviewの件数を表示します。
- 各productに対する上位3つのタグを表示します。ユーザーがレビュー作成時に自由に選んだタグの中で最も多く選ばれた3つを表示します。
既存のテーブル構造の問題点#

productとdiet_tagは多対多の関係を持ち、中間にproduct_diet_tagテーブルを置いて各productに関連するdiet_tagを保存します。
ユーザーはproductに対するreviewを作成する際、希望するdiet_tagを複数選択して保存します。したがって一つのreviewが作成されると、product_diet_tagのデータは複数保存されます。
例えば、productが100万件あって各productに100件のreviewがあると仮定してみます。そしてユーザーたちが各reviewごとに3件のdiet_tagをバランスよく選ぶなら、product_diet_tagのデータはおよそ3億件保存されます。
このような設計は問題があると判断して、テーブル構造と保存プロセスの改善が必要だと考えました。
改善されたテーブル構造#

ユーザーたちがreviewを作成するときにproduct_diet_tagにtag_countフィールドを作ってその値を増加させれば、データ容量をはるかに減らすことができます。
再び例として、productが100万件あってdiet_tagが10件だと仮定してみます。この場合product_diet_tagのデータは最大1,000万件に減ります。product_diet_tagの件数はproductの件数とdiet_tagの件数にのみ依存し、reviewの件数には影響を受けません。ユーザーがreviewをどれだけ多く登録してもtag_countの値を増加させるだけでよいです。
既存のコード(一発クエリ)#
既存のコードは以下のようにJdbcTemplateを利用してRepositoryですべてのロジックを処理していました。
@Repository
@RequiredArgsConstructor
@Transactional
public class JdbcTemplateProductSearchRepository {
private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
private final DataClassRowMapper<ProductSearchResponse> beanPropertyRowMapper = new DataClassRowMapper<>(ProductSearchResponse.class);
public Page<ProductSearchResponse> findFullTextSearch(String keyword, Pageable pageable) {
Integer total = getTotalCount(keyword);
String sql = "SELECT " +
"p.product_id, p.product_name, p.product_corp, " +
"(SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count, " +
"(SELECT AVG(r.rating) FROM review r WHERE r.product_id = p.product_id) AS review_avg_rating, " +
"(SELECT GROUP_CONCAT(diet_tag_name) FROM (SELECT dt.diet_tag_name FROM product_diet_tag pdt JOIN diet_tag dt ON pdt.diet_tag_id = dt.diet_tag_id where pdt.product_id = p.product_id ORDER BY pdt.tag_count DESC LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names " +
"FROM product p " +
"WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)" +
"LIMIT :offset, :limit";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("keyword", keyword)
.addValue("offset", pageable.getOffset())
.addValue("limit", pageable.getPageSize());
List<ProductSearchResponse> queried = namedParameterJdbcTemplate.query(sql, parameters, beanPropertyRowMapper);
return new PageImpl<>(queried, pageable, total);
}
private Integer getTotalCount(String keyword) {
String sql = "SELECT COUNT(*) " +
"FROM product p " +
"WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("keyword", keyword);
return namedParameterJdbcTemplate.queryForObject(sql, parameters, Integer.class);
}
}リファクタリングされたコード#
上記のコードをリファクタリングして以下のコードに変更しました。
// クエリにIN句を使用して各productIdに該当するレビューの件数を一度に照会します。
public interface ReviewRepository extends CrudRepository<Review, Long> {
@Query("SELECT product_id, COUNT(*) AS review_count FROM review WHERE product_id IN (:productIds) GROUP BY product_id")
List<ProductReviewCount> countReviewsByProductIds(@Param("productIds") List<Long> productIds);
}// クエリにIN句を使用して各productIdに該当するproduct_diet_tagをすべて照会します。
// diet_tagテーブルとジョインしてdiet_tag_nameを取得します。
public interface ProductDietTagRepository extends CrudRepository<ProductDietTag, Long> {
@Query("SELECT pdt.product_id, dt.diet_tag_name, pdt.tag_count " +
"FROM product_diet_tag pdt " +
"LEFT JOIN diet_tag dt on pdt.diet_tag_id = dt.diet_tag_id " +
"WHERE product_id IN (:productIds)"
)
List<ProductDietTagDto> findByProductIds(@Param("productIds") List<Long> productIds);
}@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class ProductSearchService {
// Spring Data Elasticsearchリポジトリ
private final ProductDocumentRepository productDocumentRepository;
private final ReviewRepository reviewRepository;
private final ProductDietTagRepository productDietTagRepository;
private final ProductSearchResponseMapper productSearchResponseMapper;
public Page<ProductSearchResponse> search(String keyword, Pageable pageable) {
// ProductDocumentはElasticsearchに保存された各データをマッピングします。
// Elasticsearchを使ってkeywordに該当するProductDocumentを検索します。
Page<ProductDocument> productDocuments = productDocumentRepository.findByProductName(keyword, pageable);
// 検索結果がなければ例外を返します。
if (productDocuments.isEmpty()) {
throw new BusinessException(PRODUCT_NOT_FOUND);
}
// ProductのIDリストを抽出します。
List<Long> productIds = productDocuments.stream()
.map(ProductDocument::getId)
.toList();
List<ProductDietTagDto> tags = productDietTagRepository.findByProductIds(productIds);
List<ProductReviewCount> productReviewCounts = reviewRepository.countReviewsByProductIds(productIds);
// productSearchResponseMapperクラスが
// 各productIdに合わせてtagsをcount値の降順でソートした後3件を選択する。
// また各productIdに合わせてreview件数もマッピングして結果を返す。
return productSearchResponseMapper.toResponseList(productDocuments, tags, productReviewCounts);
}
}結果として一発クエリで解決していたものを3回のDB接続に分割しましたが、複雑なクエリが不要になり、ビジネスロジックとデータアクセスロジックが強く結合していたものを分離してコードの可読性が向上しました。
リファクタリングされたコードは各役割(製品照会、レビュー照会、タグ照会)を個別に処理し、サービス層で結果を組み合わせる方式で再利用性も向上しました。
最後のテストの実施#
前回のテストとの違い#
- 上記の要件をすべて反映。
- 既存のGET(検索)リクエストとともにPOST(保存)リクエストを追加しました。実際の状況を想定してリクエストの比率はGET 90%、POST 10%に設定しました。データ保存時のMySQLとElasticsearchのデータ同期はアプリケーションで直接管理する最もシンプルな方式を選択しました。
- テスト時間を25分に増加。前回のテストで要件のないElasticsearch単独テストですでにある程度の性能が出ることを確認したため、少し長い時間テストを実施しました。

ユーザーは段階的に増加し、4分程度経過した後300人を維持しました。
テスト結果#


要件なしの検索(GET)リクエストテストと比較したとき、TPSが192.5から179.6にわずかに減少しました。
各APIリクエスト別に平均応答時間を見てみると、GETリクエストは876ms、POSTリクエストはGETリクエストより約400ms程度多くかかる**1.21秒(1,210ms)**の結果が現れました。POSTリクエストはElasticsearchエンジンへの書き込み作業(読み込みに比べて相対的に遅い)とMySQLへの書き込み作業を同期的に処理しており、この部分でより多くの時間がかかったと判断されます。


アプリケーションサーバーのCPU使用量は大よそ20〜30%程度となりました。
そして全文検索のみを使用するときに軽く100%になっていたMySQLサーバーのCPU使用量も**20〜25%**程度に減少しました。
目標対比分析: GET 876ms、POST 1,210msでVUser 300人の負荷基準です。応答時間の最小値がGET 144ms、POST 254msであることを考えると、負荷が低い状況では目標に近づくことが可能です。ただしVUser 300人での平均応答時間はまだ目標を超えているため、追加の改善が必要です。
今回のテストで不十分だった点#
今回のテストを実施して一つ残念だった点がありました。
目標を設定する際、現実的なピークRPSを約15と推定しましたが、正作**その負荷レベルでのテストは実施しませんでした。**すべてのテストをVUser 300人という高い負荷でのみ実施したため、目標負荷で実際にどの程度の性能が出るか確認できませんでした。
ストレステストを通じてシステムの限界点を把握したことは意義がありましたが、正しい性能テストの順序は以下のようであるべきでした。
- 目標負荷テスト:現実的なピーク(15 RPS)で目標応答時間を達成するか確認
- ストレステスト:段階的に負荷を高めてシステムがどこで限界に達するかを把握
- 限界分析と改善:ボトルネック地点を分析して改善後、再び1番から繰り返す
今回は2番から直接実施したことになります。今後同様の性能テストを実施するなら、目標負荷での検証を先に実施した後、段階的に負荷を高める順序でアプローチする方がより適切だと判断します。
次の記事: Spring Boot Tomcat threads.maxとHikariCP maximum-pool-sizeをデフォルトに設定したときに現れる現象
