Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Rails6 にアップグレードしたら update_all が遅くなったので対応した話

はじめに

こんにちは、広告技術部の fujishiro です。今年の GW は RIZIN の大会が 2 大会あり、どちらも PPV を購入して観戦していました。特に RIZIN.42 はとても面白かったですね。

さて、広告技術部が長く管理している Rails 製の Gunosy Ads の管理画面があるのですが、先日その管理画面を Rails 5.2 系から Rails 6.0 系にアップグレードしました。

ただ、その中で複合主キーを使用している MySQL のテーブルに対して update_all が遅くなってしまったので、今回はその原因と対応した内容についてご紹介します。

Rails アップグレード時におこなった手順

まず、Rails アップグレード時におこなった手順を紹介します。 手順としては、おおまかに以下の作業をおこないました。

  • Rails 5.2 系の最新バージョンにアップグレード
    • DEPRECATION WARNING の解消
    • bundle update の実行
    • rails app:update の実行
    • テストの修正やコードの修正
  • Rails 6.0 系の最新バージョンにアップグレード
    • bundle update の実行
    • rails app:update の実行
    • テストの修正やコードの修正

update_all が遅くなった

アップグレードの手順を一通りおこなった後、複合主キーを使用している MySQL のテーブルに対して update_all を実行したところ、Rails 5.2 系の時よりも遅くなっていることに気づきました。

今回は、遅くなった原因についてサンプルのコードとテーブルを用意して説明していきます。

まず、以下のようなテーブルを用意します。

post_comment_approvals テーブル

テーブル定義

列名 説明
post_id BIGINT(20) 投稿 ID, comment_id との複合主キー
comment_id BIGINT(20) コメント ID, post_id との複合主キー
status VARCHAR(8) 承認ステータス, accept, decline, pending

テーブルデータ(例)

post_id comment_id status
1 1 accept
1 2 reject
2 1 pending
2 2 pending

Model は以下のようになっています。

post_comment_approval.rb

class PostCommentApproval < ApplicationRecord
  self.primary_keys = :post_id, :comment_id
  enum status: { accept: 'accept', decline: 'decline', pending: 'pending' }
  ~~以下略~~
end

そして Gemfile には以下のように記述しています。

Gemfile (Rails 5.2 系の最新バージョン)

source 'https://rubygems.org'

gem 'rails', '5.2.8.1'
gem 'composite_primary_keys'
gem 'mysql2', '~> 0.5.4'
~~以下略~~

Gemfile (Rails 6.0 系にアップグレード後)

source 'https://rubygems.org'

gem 'rails', '6.0.6.1'
gem 'composite_primary_keys'
gem 'mysql2', '~> 0.5.4'
~~以下略~~

このような状態で、以下のようなコードを実行したとします。

PostCommentApproval
  .where(post_id: [1, 2], comment_id: [1, 2])
  .update_all(status: :accept)

このコードは、PostCommentApproval という Model の post_idcomment_id が指定した値のレコードの statusaccept に更新するというものですが、Rails 6.0 系にアップグレードをする前とした後で発行される SQL が変わってしまっていることに気づきました。

Rails 6.0 系にアップグレードする前は以下のような SQL が発行されていました。

UPDATE `post_comment_approvals`
SET
    `post_comment_approvals`.`status` = 'accept'
WHERE
    `post_comment_approvals`.`post_id` IN (1, 2)
    AND `post_comment_approvals`.`comment_id` IN (1, 2)

至ってシンプルな SQL です。

しかし、アップグレード後は以下のような SQL が発行されるようになりました。

UPDATE `post_comment_approvals`
SET `post_comment_approvals`.`status` = 'accept'
WHERE (
    post_comment_approvals.post_id,
    post_comment_approvals.comment_id
) IN (
    SELECT post_id, comment_id
    FROM (
        SELECT DISTINCT
            `post_comment_approvals`.`post_id`,
            `post_comment_approvals`.`comment_id`
        FROM `post_comment_approvals`
        WHERE
            `post_comment_approvals`.`post_id` IN (1, 2)
            AND `post_comment_approvals`.`comment_id` IN (1, 2)
    ) __active_record_temp
)

これを見ると、アップグレード前は単純に post_idcomment_id が指定した値のレコードを更新していましたが、アップグレード後はサブクエリを発行していることがわかります。

実際のコードでは同じデータベースに接続している状況でアップグレード前は 16.4ms で終わっていたのが、アップグレード後は 21150.5ms となってしまい、約 1300 倍も遅くなってしまいました……。

なぜ遅くなったのか

調べた結果、原因は Rails 自体ではなく、Gem の composite_primary_keys にあることがわかりました。

composite_primary_keys は、ActiveRecord で複合キーを扱うための拡張 Gem です。

この場合だと通常の ActiveRecord の update_all では複合キーを扱うことができないため、composite_primary_keys が提供する update_all が呼ばれることになります。

そして、composite_primary_keys が提供する update_all は、以下のコードになっていました*1

github.com

ここで複合キーを使っている場合は、サブクエリを発行するようになっています。

この部分のコードは ActiveRecord 6.0 系で動作するように設計がされている composite_primary_keys 12 系から導入されており、Rails 6.0 系にアップグレードした際の bundle update 実行時に ActiveRecord の バージョンに合わせて composite_primary_keys のバージョンが 12 系に上がっていたことによる挙動の変化が原因でした*2

対応策

現状この問題に対しては、update_all を使わず、生 SQL にすることで対応しています。

PostCommentApproval
  .where(post_id: [1, 2], comment_id: [1, 2])
  .update_all(status: :accept)

上のコードを以下のように変更しました。

sql = <<~SQL.squish
  UPDATE post_comment_approvals AS pca
  SET pca.status = 'accept'
  WHERE pca.post_id IN (#{[1, 2].join(',')})
  AND pca.comment_id IN (#{[1, 2].join(',')})
SQL
ApplicationRecord.connection.execute(sql)

このように変更することで、アップグレード前とほぼ同じ処理時間で実行されるようになりました。

とはいえ、できる限り ActiveRecord のメソッドを使いたいところです。

まとめ

今回は、弊チームが管理している管理画面を Rails 6.0 系へのアップグレードをしてから特定のテーブルに対しての update_all が遅くなってしまったことについてご紹介しました。

現状は生 SQL にすることで対応していますが、できる限り ActiveRecord のメソッドを使いたい気持ちはあるので、複合キーを使わないなどの改修をしていくことも今後検討していければと思っています。

*1:ActiveRecord 6.0 系のブランチである ar_6.0.x ブランチのコードになります。

*2:ActiveRecord 5.2 系に対応している ar_5.2.x ブランチのコードでは導入されていませんでした。 https://github.com/composite-primary-keys/composite_primary_keys/blob/ar_5.2.x/lib/composite_primary_keys/relation.rb#L19-L51