はじめに
Laravelアプリケーションでよく見かける「ネストしたループ」。データ量が少ないうちは問題ありませんが、データが増えると急激にパフォーマンスが悪化します。
今回は、Collection::keyBy()
を利用したO(N²)の計算量を改善する方法を、ECサイトの注文処理を例に紹介します。
問題のあるコード(O(N²))
ECサイトで、外部システム(配送業者のAPI等)から受け取った配送ステータスを注文データに反映する処理を考えてみましょう。
public function syncOrderStatuses(array $statusUpdates): array
{
// APIやCSVから受け取ったステータス更新データ
// [
// ['order_number' => 'ORD-001', 'status' => 'shipped', 'tracking_number' => 'TRK-12345'],
// ['order_number' => 'ORD-002', 'status' => 'delivered', 'delivered_at' => '2024-01-15'],
// ... 1000件以上
// ]
$orderNumbers = array_column($statusUpdates, 'order_number');
$orders = Order::whereIn('order_number', $orderNumbers)->get();
$updatedOrders = [];
// ⚠️ ここが問題!外部データごとに全注文をループ
foreach ($statusUpdates as $update) {
foreach ($orders as $order) {
if ($order->order_number === $update['order_number']) {
// ステータスが変更されている場合のみ更新
if ($order->status !== $update['status']) {
$order->status = $update['status'];
// 配送済みの場合は配送日時も更新
if ($update['status'] === 'delivered' && isset($update['delivered_at'])) {
$order->delivered_at = $update['delivered_at'];
}
// 追跡番号があれば更新
if (isset($update['tracking_number'])) {
$order->tracking_number = $update['tracking_number'];
}
$order->save();
$updatedOrders[] = $order->order_number;
}
break; // 見つかったらループを抜ける
}
}
}
return $updatedOrders;
}
何が問題?
上記のコードを見ると、2重ループを使って全注文を取り出しています。
注文件数やループ内の処理によってはパフォーマンスに大きく影響が出るので避けたい形です。
- 外部データ数: N(例:1000件)
- 注文データ数: M(例:1000件)
- 計算量: O(N × M) ≈ O(N²)
1000件のステータス更新 × 1000件の注文 = 100万回の比較処理!
改善後のコード(O(N))
keyByメソッドを利用して改善したコードがこちらです。
public function syncOrderStatuses(array $statusUpdates): array
{
// APIやCSVから受け取ったステータス更新データ
// [
// ['order_number' => 'ORD-001', 'status' => 'shipped', 'tracking_number' => 'TRK-12345'],
// ['order_number' => 'ORD-002', 'status' => 'delivered', 'delivered_at' => '2024-01-15'],
// ... 1000件以上
// ]
$orderNumbers = array_column($statusUpdates, 'order_number');
$orders = Order::whereIn('order_number', $orderNumbers)->get();
// keyBy()で注文番号をキーにしたコレクションを作成
$ordersByNumber = $orders->keyBy('order_number');
$updatedOrders = [];
foreach ($statusUpdates as $update) {
// O(1)で直接アクセスできるため、ネストループ不要になっている
$order = $ordersByNumber->get($update['order_number']);
if ($order && $order->status !== $update['status']) {
$order->status = $update['status'];
// 配送済みの場合は配送日時も更新
if ($update['status'] === 'delivered' && isset($update['delivered_at'])) {
$order->delivered_at = $update['delivered_at'];
}
// 追跡番号があれば更新
if (isset($update['tracking_number'])) {
$order->tracking_number = $update['tracking_number'];
}
$order->save();
$updatedOrders[] = $order->order_number;
}
}
return $updatedOrders;
}
改善のポイント
-
keyBy('order_number')
で注文番号をキーにしたコレクションを作成 -
get()
メソッドでO(1)の計算量で直接アクセス - 計算量: O(N²) → O(N)に改善
- 改善前: 1000件 × 1000件 = 100万回の比較
- 改善後: 1000件の処理のみ = 1000回のアクセス
- 単純比較で1000倍の高速化!
注意点
1. キーの重複
キーはユニークであることが前提です。
// ⚠️ 同じキーがある場合、後のものが優先される
$collection = collect([
['id' => 1, 'name' => 'A'],
['id' => 1, 'name' => 'B'], // これが残る
])->keyBy('id');
// $collection->get(1) => ['id' => 1, 'name' => 'B']
2. メモリ使用量
コレクションを利用する場合は共通ですが、必要なデータのみ取得して利用しましょう。
// ⚠️ keyBy()は新しいコレクションを作成する
$products = Product::all(); // 元のコレクション
$productsById = $products->keyBy('id'); // 新しいコレクションが作成される
// 💡 メモリが気になる場合は、最初から必要なデータのみ取得
$products = Product::whereIn('id', $productIds)
->select(['id', 'name', 'price']) // 必要なカラムのみ
->get()
->keyBy('id');
まとめ
今回はCollectionのkeyByメソッドを紹介しました。
LaravelのEloquentやCollectionは気を抜くとパフォーマンスに問題のあるコードを量産してしまいます。
しかし、解決するための便利な機能も豊富にあるので臆せず使っていきましょう!