はじめに
こんにちは!株式会社High Link ロジスティクス開発エンジニアのかんたろう(@kantarow2813)です。
今回はRailsのincludesを使用していて遭遇したロックの問題と、その解決方法及び対策について紹介します。
includesとは
includesとはActiveRecordが提供するクエリインターフェイスのひとつで、クエリする際に関連先レコードを一括読み込み(eager loading)する機能です。
includesには二つの一括読み込み戦略があり、検索条件に応じてよしなに使い分けるようになっています。二つの戦略はそれぞれpreloadとeager_loadというメソッド名で提供されており、前者は複数クエリ、後者はLEFT OUTER JOINで一括読み込みを実現します。
preload | eaager_load | |
---|---|---|
使用される条件 | クエリ内で結合先のテーブルのカラムが使われていない場合 | クエリ内で結合先のテーブルのカラムが使われている場合 |
発行されるクエリ | 結合先ごとのIDによる検索 | LEFT OUTER JOIN |
includesを使用する場合、これらのメソッドが暗黙的に使い分けられる点を問題視する意見もあります。
preloadとeager_loadではパフォーマンスの特性が異なるため、開発者各々の判断で個別に使い分けるべきという指摘が主ですが、本記事ではロックと併用する場合のリスクについて触れます。
ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由 - Qiita
【Rails】N+1を回避するメソッド(includes, eagar_load, preload)の使い分けについて
includesを使用した際に発生した障害
本記事を書くきっかけになった障害は、デッドロックによって特定のバッチ処理が停止するというものです。この問題の原因を調査していく過程で、includesとlock!を併用している箇所で想定していたより大きい範囲のロックが発生していることがわかりました。
MySQLではJOINを含むクエリを用いてロックする場合、JOINされたテーブルにもロック範囲が及ぶようになっています。今回はincludesがLEFT OUTER JOINによる一括読み込みを選択するような検索を行なっていたため、更新する予定のない関連先テーブルに排他ロックがかかる事象が発生していました。
この意図しない排他ロックが、他トランザクションからの共有ロックと競合しデッドロックの原因になっていたため、排他ロックを避けることで解決できることが分かります。今回はJOINをサブクエリに書き換えることでロック対象を更新予定のテーブルにとどめ、デッドロックを根本から解消することができました。
再発防止のためのチームの取り組み
上記の問題を改めて評価すると、決してincludesが直接的な原因となっている訳ではありません。しかし、一括読み込みがどのような方法で行われているか一目で把握できないことは、今回のように意図しないロック範囲の拡大を引き起こす可能性があるため、includesの使用を非推奨化することを決定しました。これにより開発者が状況に応じてpreloadとeager_loadを使い分ける必要が生まれますが、二つのメソッドの差異を意識することでミスを減らしたり、問題が起こった際に原因を見つけやすくする効果を期待しています。
また継続的にincludesの使用をチェックするためにRubocopのカスタムルールを作成し、includesの使用を検知・指摘するようにしました。以下のような実装になっています。
class RuboCop::Cop::Style::ReplaceIncludes < RuboCop::Cop::Base def_node_matcher :match?, '(send ... :includes _)' MSG = 'Use preload or eager_load instead of includes.'.freeze RESTRICT_ON_SEND = [:includes].freeze def on_send(node) return unless match?(node) add_offense(node) end end
弊社が開発するカラリアのプロジェクトでは、Rubocopに違反した場合プルリクエストにbotのコメントが付くようになっているため、マージする前に修正することができます。
おわりに
今回はincludesを使用していて起こった問題と、再発防止のための取り組みについて触れました。
includesの扱いに迷っている方がこの記事を読んで判断の一助にしていただけたら幸いです。
参考記事
ロック範囲を制御する際の参考記事
innodbでサブクエリを使ったときの FOR UPDATE のロックの範囲 - ngyukiの日記
ロック状況の調査を行う際の参考記事
MySQL :: MySQL 8.0 リファレンスマニュアル :: 15.7.1 InnoDB ロック
MySQL :: MySQL 8.0 リファレンスマニュアル :: 27.12.13.1 data_locks テーブル
カスタムcopを作成する際の参考記事