【ISUCON14】全件集計をCTEで「事前絞り込み」してSQLを33倍高速化した話

ISUCON14のチューニングにおいて、MySQLスロークエリログ(pt-query-digest)で最も重かったクエリを、共通テーブル式(CTE: WITH 句)による事前絞り込みと複合インデックス追加によって改善。

対象SQLの実行時間を 512ms から 15.4ms(約33倍高速化) に短縮し、ベンチマークスコアを 1,323点から1,995点 へ引き上げた技術的な仕組みと手順。


TL;DR:この記事のまとめ

要するに、「全件計算してから最後に絞り込む」非効率なSQLを、「先に必要な行だけ絞り込んでから計算する」構成に書き換えた記録。

  • 問題の発見: pt-query-digest で最悪クエリ(DB総時間の26.9%、合計約85秒)を特定。
  • 原因: 1人のオーナーの椅子一覧を表示したいだけなのに、サブクエリ内で全椅子の位置情報履歴(約2.5万行)に対してWindow関数(LAG)で距離計算を実行。
  • 対策: CTE(WITH 句)を使って対象オーナーの椅子だけを先に絞り込んでから距離計算を行い、さらに (chair_id, created_at) の複合インデックスを追加。
  • 結果: 検査行数を 24,897行から414行へ98%削減 し、1回あたりの処理時間を 512ms → 15.4ms へ短縮。

1. スロークエリ解析で「最悪クエリ」を発見する

ベンチマーク実行後、pt-query-digest でMySQLのスロークエリログを解析したところ、以下のクエリが圧倒的1位として浮上。

# Profile
# Rank Query ID                            Response time Calls R/Call V/M 
# ==== =================================== ============= ===== ====== ====
#    1 0xFA8D713EE4B9E473194D3A91C75FE99F  84.9800 26.9%   166 0.5120  0.00 SELECT chairs chair_locations distance_table

この数値からわかること

  • 合計時間: 84.98秒(DB全体の処理時間の 26.9% を1つのSQLが占有)
  • 呼出回数: 166回
  • 平均時間: 512ms(約0.5秒)

該当ハンドラーは、オーナー画面で自分の椅子一覧を取得する ownerGetChairs/api/owner/chairs)。


2. 変更前のSQLと問題点

変更前のSQL

SELECT id,
       owner_id,
       name,
       access_token,
       model,
       is_active,
       created_at,
       updated_at,
       IFNULL(total_distance, 0) AS total_distance,
       total_distance_updated_at
FROM chairs
       LEFT JOIN (SELECT chair_id,
                          SUM(IFNULL(distance, 0)) AS total_distance,
                          MAX(created_at)          AS total_distance_updated_at
                   FROM (SELECT chair_id,
                                created_at,
                                ABS(latitude - LAG(latitude) OVER (PARTITION BY chair_id ORDER BY created_at)) +
                                ABS(longitude - LAG(longitude) OVER (PARTITION BY chair_id ORDER BY created_at)) AS distance
                         FROM chair_locations) tmp
                   GROUP BY chair_id) distance_table ON distance_table.chair_id = chairs.id
WHERE owner_id = ?;

【図解】そもそもWindow関数と LAG() とは何をしているのか

SQL初心者が最初につまずきやすいのが、このサブクエリ内で使われている Window関数(OVER (...))と LAG()

「椅子の移動距離を計算したい」というユースケースにおいて、通常の GROUP BY ではなぜ駄目で、なぜWindow関数が必要なのかを図解する。

■ 普通の GROUP BY(行が1行に潰れてしまう)
┌──────────┬───────────┐
│ chair_id │ latitude  │
├──────────┼───────────┤
│ chair_A  │ 10 (12:00)│ ──┐
│ chair_A  │ 15 (12:05)│ ──┼─▶ 【合計/平均】 1行に潰れる(前後の差分は取れない)
│ chair_A  │ 20 (12:10)│ ──┘
└──────────┴───────────┘

■ Window関数(行を潰さずに「直前の行」の値を参照できる)
┌──────────┬───────────┬──────────────────────────────────────────┐
│ chair_id │ latitude  │ LAG(latitude) で1つ前の行の値を取得      │
├──────────┼───────────┼──────────────────────────────────────────┤
│ chair_A  │ 10 (12:00)│ NULL (最初なので前がない)              │
│ chair_A  │ 15 (12:05)│ 10   (1つ前の 10 を取得 → 差分は 5)    │
│ chair_A  │ 20 (12:10)│ 15   (1つ前の 15 を取得 → 差分は 5)    │
└──────────┴───────────┴──────────────────────────────────────────┘

構文の役割

LAG(latitude) OVER (PARTITION BY chair_id ORDER BY created_at)
  • PARTITION BY chair_id: 椅子ごとにグループを分割(別々の椅子の座標が混ざらないように分離)。
  • ORDER BY created_at: 時系列順に整列。
  • LAG(latitude): 1行手前(直前)の座標値を取得。

この LAG() によって「前回の位置との差分(移動距離)」を算出し、外側の GROUP BY で合計距離を出す構造。

なぜこのSQLは遅いのか

このSQLは「指定された owner_id が所有する椅子の情報と、それぞれの総移動距離を返すこと」が目的。

しかし、実行の流れを見ると致命的な無駄が存在していた。

  1. 内側のサブクエリ(tmp: – chair_locations テーブルの**全データ(約2.5万行)**を全走査。 – すべての椅子(他人の所有する椅子を含む)について、Window関数 LAG() を使って前回の座標との差分(移動距離)を1行ずつ計算。
  2. 中間のサブクエリ(distance_table: – 全椅子の計算結果を chair_idGROUP BY し、合計距離(SUM)と最終更新日時(MAX)を算出。
  3. 最外層のクエリ: – 最後に WHERE owner_id = ? で、特定のオーナーの椅子だけに絞り込み。

つまり、「全オーナーの全椅子の距離計算をすべて終わらせてから、最後の最後に対象オーナー分だけを切り出す」という極めて無駄な処理。


3. 改善方針:CTEによる「事前絞り込み」

CTE(共通テーブル式: Common Table Expression)とは

WITH 一時テーブル名 AS (SELECT ...) の形式で記述し、クエリ内で一時的な結果セットを定義する構文。

今回はCTEを使って以下の手順に組み替え。

  1. Step 1: 対象 owner_id の椅子(数件)だけを先に抽出(owned_chairs)。
  2. Step 2: chair_locationsowned_chairsINNER JOIN し、計算対象の行数をそのオーナーの椅子だけに絞り込み
  3. Step 3: 絞り込まれた数百行に対してのみ、Window関数 LAG() による距離計算と GROUP BY を実行。

改善後のSQL

WITH owned_chairs AS (
  SELECT *
  FROM chairs
  WHERE owner_id = ?
),
distance_table AS (
  SELECT
    chair_id,
    SUM(IFNULL(distance, 0)) AS total_distance,
    MAX(created_at) AS total_distance_updated_at
  FROM (
    SELECT
      cl.chair_id,
      cl.created_at,
      ABS(
        cl.latitude -
        LAG(cl.latitude) OVER (
          PARTITION BY cl.chair_id
          ORDER BY cl.created_at
        )
      ) +
      ABS(
        cl.longitude -
        LAG(cl.longitude) OVER (
          PARTITION BY cl.chair_id
          ORDER BY cl.created_at
        )
      ) AS distance
    FROM chair_locations AS cl
    INNER JOIN owned_chairs AS oc
      ON oc.id = cl.chair_id
  ) AS locations_with_distance
  GROUP BY chair_id
)
SELECT
  oc.id,
  oc.owner_id,
  oc.name,
  oc.access_token,
  oc.model,
  oc.is_active,
  oc.created_at,
  oc.updated_at,
  IFNULL(dt.total_distance, 0) AS total_distance,
  dt.total_distance_updated_at
FROM owned_chairs AS oc
LEFT JOIN distance_table AS dt
  ON dt.chair_id = oc.id;

4. インデックス追加による仕上げ

SQLの構造改善に加えて、chair_locations テーブルに適切なインデックスを追加。

追加したインデックス

-- webapp/sql/1-schema.sql(chair_locations テーブル定義内)
INDEX idx_chair_locations_chair_id_created_at (chair_id, created_at)

なぜこの複合インデックスが必要なのか

Window関数の部分:

OVER (PARTITION BY cl.chair_id ORDER BY cl.created_at)

この処理は、「chair_id ごとにグループ化し、created_at の昇順に並べ替えて直前の行と比較する」という動作。

(chair_id, created_at) の複合インデックスが存在すれば、データベースはメモリ上でのソート処理(Filesort)を行うことなく、インデックスの並び順のままデータを走査できる


5. 計測結果(Before / After)

単体実行計画(EXPLAIN ANALYZE)の比較

特定のオーナーIDを指定して EXPLAIN ANALYZE を実行した結果。

改善前(旧SQL:全2.5万行スキャンとFilesort)

改善前のEXPLAIN ANALYZE

改善後(新CTE SQL:インデックス参照と走査行数の激減)

改善後のEXPLAIN ANALYZE
項目 改善前 改善後 改善効果
実行時間 165〜189 ms 16.2〜40 ms 約4〜11倍 高速化
Window関数 走査行数 24,924 行 4,076 行(椅子単位でさらに414行へ) 83.6〜98.3% 削減

ベンチマーク全体での比較

項目 改善前 改善後 改善効果
対象SQLの合計時間 84.98 秒 2.55 秒 約97% 短縮
対象SQLの平均時間 512 ms 15.4 ms 約33.2倍 高速化
対象SQLのDB負荷占有率 26.9%(1位) 1.0%(14位) ボトルネックから完全に脱落
ベンチマークスコア 1,323〜1,595 点 1,995 点 +50.8% 向上

6. まとめ:SQLチューニングの原則

今回の改善から得られた汎用的な教訓2点:

  1. 重い集計(GROUP BY / Window関数)は、できる限り手前で対象行数を絞り込んでから実行 – サブクエリで全件集計した後に外側で WHERE 絞り込みを行う構成は、典型的なアンチパターン。 – CTE(WITH 句)を使って対象レコードのみを先に抽出することで、計算コストを劇的に抑制。
  2. PARTITION BY col1 ORDER BY col2 には (col1, col2) の複合インデックスを貼る – Window関数が必要とするソート順と完全に一致するインデックスを用意することで、余分なソート負荷をゼロ化。