こんにちは、ヤプリでサーバサイドエンジニアをしている熊埜御堂です。 今回は、業務で行ったAPIのパフォーマンス改善について記載します。
TL;DR
- 何が起きていたか: EXPLAINで
event_historiesへのクエリが全144パーティションをスキャンしていた - なぜ起きていたか: サブパーティションキー(
tenant_id)がWHERE句に含まれておらず、MySQLがパーティション剪定できなかった - どう直したか: Eloquentの
with()に.where('tenant_id', $tenant->id)を追加 - 効果: スキャンパーティション数 144 → 9(16倍改善)、該当APIのスループット 97 RPS → 263 RPS(2.7倍改善)
何が問題だったか
該当APIにおいて、event_historiesへのクエリがボトルネックとなり目標スループットを達成していなかった。
イベント履歴テーブル event_histories へのクエリを EXPLAIN で調査したところ、以下の結果が得られました。
修正前のEXPLAIN(抜粋):
| key | rows | partitions |
|---|---|---|
item_id_occurred_at_index |
31 | p_min_sp0, p_min_sp1, ... p_max_sp15(全144件) |
推定31行を取得するクエリにもかかわらず、144個すべてのパーティションがスキャンされていました。
どこで起きていたか
問題があったクエリ
// アイテム一覧取得(イベント履歴をeager loading)
Item::where('tenant_id', $tenant->id)
->where('member_id', $member->id)
->with(['eventHistories'])
->get();
テーブル構造
event_histories は以下の2段階パーティション構造を持っています。
CREATE TABLE `event_histories` ( `id` bigint unsigned AUTO_INCREMENT, `tenant_id` char(36) NOT NULL, `item_id` bigint UNSIGNED NOT NULL, `occurred_at` datetime NOT NULL, PRIMARY KEY (`id`, `tenant_id`, `occurred_at`), INDEX event_histories_item_id_occurred_at_index (`item_id`, `occurred_at`) ) PARTITION BY RANGE (YEAR(occurred_at)) -- 第1段階: 年ごとにパーティション分割 SUBPARTITION BY KEY (tenant_id) -- 第2段階: tenant_id のハッシュでさらに16分割 SUBPARTITIONS 16 ( PARTITION p_min VALUES LESS THAN (2023), PARTITION p2023 VALUES LESS THAN (2024), PARTITION p2024 VALUES LESS THAN (2025), PARTITION p2025 VALUES LESS THAN (2026), PARTITION p2026 VALUES LESS THAN (2027), PARTITION p2027 VALUES LESS THAN (2028), PARTITION p2028 VALUES LESS THAN (2029), PARTITION p2029 VALUES LESS THAN (2030), PARTITION p_max VALUES LESS THAN (MAXVALUE) );
これにより合計 9年 × 16サブパーティション = 144パーティション が存在します。各パーティションは独立したインデックスセグメントを持ちます。
なぜ起きていたか
Eloquentが生成するクエリ
eventHistories リレーションは以下のように定義されており、Eloquentは item_id のみを条件としたクエリを生成します。
// Item モデル
public function eventHistories(): HasMany
{
return $this->hasMany(EventHistory::class, 'item_id');
}
eager loading時に生成されるSQL:
SELECT * FROM event_histories WHERE item_id IN (1, 2, 3, ...) -- ← occurred_at も tenant_id も条件にない
MySQLのパーティション剪定の仕組み
MySQLのパーティション剪定(Partition Pruning)は、クエリ実行前にWHERE句を静的解析してスキャン不要なパーティションをスキップする最適化です。
| パーティションキー | 剪定に必要な条件 | 生成されたクエリの状態 |
|---|---|---|
YEAR(occurred_at) (年パーティション) |
occurred_at の範囲条件 |
なし → 全9年をスキャン |
tenant_id (サブパーティション) |
tenant_id = ? の等値条件 |
なし → 全16サブをスキャン |
MySQLはデータの内容を実行前に参照しません。 実際にはすべての履歴行が同じ tenant_id を持っていても、WHERE句にその条件がなければ「どのサブパーティションにあるか不明」と判断し、全16個を物理的に開いて確認します。
どう直したか
解決策: tenant_id をWHERE句に追加
tenant_id はサブパーティションキーです。WHERE句に加えることで、MySQLは HASH(tenant_id) % 16 を計算して対象サブパーティションを1つに特定できます。
// Before ->with(['eventHistories']) // After ->with([ 'eventHistories' => fn ($q) => $q->where('tenant_id', $tenant->id), ])
効果の検証
EXPLAINによる確認
修正後のEXPLAIN(抜粋):
| key | rows | partitions |
|---|---|---|
item_id_occurred_at_index |
31 | p_min_sp7, p2023_sp7, ... p_max_sp7(9件) |
HASH(tenant_id) % 16 = 7 と計算され、各年パーティション内のサブパーティション#7のみをスキャンするようになりました。
| 指標 | 修正前 | 修正後 |
|---|---|---|
| スキャンパーティション数 | 144 | 9 |
| 改善倍率 | — | 16倍 |
| 返却データ | 変わらず | 変わらず |
負荷試験による確認
EXPLAINでの改善を確認した後、実際のAPIエンドポイントに対して負荷試験を実施しました。
| 指標 | 修正前 | 修正後 |
|---|---|---|
| スループット | 97 RPS | 263 RPS |
| 改善倍率 | — | 約2.7倍 |
MySQLのCPU負荷が下がったことで、APIサーバ全体のスループットが2.7倍に改善しました。
まとめと教訓
MySQLのパーティション剪定をするためにはWHERE句に明示的にパーティションキーの条件を書くことが必要です。明示的に書くことでオプティマイザ(クエリ計画を担当する)が、クエリを実行する前にスキャン対象物理ファイルを絞ってくれます。
今回のケースで言えば:
- 年パーティション剪定:
WHERE occurred_at >= ?のようなoccurred_atの範囲条件が必要 - サブパーティション剪定:
WHERE tenant_id = ?のような等値条件が必要
今回の対応では「年パーティション(occurred_at)」側の剪定はおこなわず、サブパーティションのみを絞り込みましたが、これだけでも約2.7倍の性能向上という十分な成果が得られました。年パーティーションについては、クエリ結果に影響のある修正となるため、影響範囲と仕様の調整後に対応する予定です。
ORMを使っていると生成されるSQLの内容を見落としがちですが、EXPLAIN を定期的にチェックすることで今回のような非効率を早期に発見できます。特にパーティション構成のあるテーブルでは、EXPLAIN の partitions 列が大きな数になっていないか確認すると良いでしょう。
ヤプリではAIに興味があるエンジニアを大募集しています! 興味を持った方は、ぜひカジュアル面談にお越しください!