[Rails + MySQL]サブクエリを持つクエリの実行順とサブクエリ最適化

109827 ワード

はじめに

こんにちは、 です。

今回の記事は、サブクエリを持つクエリの実行順と、サブクエリの最適化について公式の説明を交えながら説明する話です。

サブクエリの最適化について何も知らず使った結果、パフォーマンス低下を招いてしまい、原因分析や修正など大変な経験をしました。事前に知っていれば避けられた問題でした。
この問題を少しでも避けてもらいたいと思い、サブクエリを持つクエリの実行順、サブクエリを使用したときにMySQLが選択しうる最適化について記事を書きました。
MySQLについてあまり詳しくないため、間違っている箇所等あれば指摘をお願いします。

なお、本記事ではMySQL 5.7.35を使用した前提となっています。

この記事で伝えたいこと

  • 私が出会ったサブクエリの問題と対策
  • サブクエリを持つクエリの実行順
  • サブクエリ最適化の概要と適用条件

背景

以前、Railsにてサブクエリを使ったコードを書いていました。
そのままのコードは載せられないので、以下のようなサンプルコードで説明します。

20210522040855_create_users
class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.string :name
      t.string :email

      t.timestamps null: false
    end
  end
end
20210522040952_create_microposts
class CreateMicroposts < ActiveRecord::Migration
  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user, index: true, foreign_key: true

      t.timestamps null: false
    end
  end
micropost.rb
class Micropost < ActiveRecord::Base
  belongs_to :user
end

サブクエリを使った実装コード:

users = User.where(email: '[email protected]')
microposts = Micropost.where(user: users).where("created_at < ?", "2022-04-20 00:00:00")
microposts.update_all(content: 'xxx')

指定のメールアドレスを持つユーザにて、作成日時が指定日時より前のコンテンツ内容を更新する処理となります。このときusersテーブル、micropostsテーブルにはそれぞれ大量のレコード(100万件)が存在していました。

上記コードを実行すると、実行クエリは以下のようになります。

  SQL (30891.9ms)  UPDATE `microposts` SET `microposts`.`content` = 'xxx' WHERE `microposts`.`user_id` IN (SELECT `users`.`id` FROM `users` WHERE `users`.`email` = '[email protected]') AND (created_at < '2022-04-20 00:00:00')

実行されたクエリは30秒以上もかかっていますね。サンプルではこのくらいの秒数ですが、実際にはこれ以上の時間がかかり、DBに負荷がかかっていました。

この現象を確認された方から、「このクエリはDBに負荷がかかる」、「サブクエリは重いので使わないほうが良い」といったご指摘をいただきました。
そのため、なぜ負荷がかかるのか、サブクエリを使用したとき何が起きるのかを調べ始めました。

先に背景の問題の結論

背景にあるクエリを最適化した場合、サブクエリ最適化が適用され、EXISTS戦略が選択されるようです。
その結果、外側のクエリで取得したuser_idそれぞれに対し、サブクエリのmicropostsテーブルの検索が行われてしまい、DBの負荷増加につながっていました。

対策として、サブクエリ自体を別に分けてあらかじめ実行しておき、その結果をもとに外側のクエリを実行するようにしました。この他にも対策はあると思いますが、今回はこの方法を選択しました。
改善後のコードと実行されたクエリは以下のとおりです。

user_ids = User.where(email: '[email protected]').pluck(:id)
microposts = Micropost.where(user: user_ids).where("created_at < ?", "2022-04-20 00:00:00")
microposts.update_all(content: 'xxx')
(1130.4ms)  SELECT `users`.`id` FROM `users` WHERE `users`.`email` = '[email protected]'
SQL (5.6ms)  UPDATE `microposts` SET `microposts`.`content` = 'xxx' WHERE `microposts`.`user_id` IN (1, 12) AND (created_at < '2022-04-20 00:00:00')

変更前後を見てわかる通り、実行時間が大幅に短縮されました。
なお、IN句にセットされるIDが大量にある場合、テーブルフルスキャンになってしまう可能性があるので、IN句にセットするID一覧を細切れにし、複数回に分けて更新する等必要です。
参考: