【Rails】トランザクションを張るときにSQLキャッシュで気にすべきこと

こんにちは!
マネーフォワードでインターンをしています、鈴木寛史です。

この記事では、私たちのチームで提供しているAPIで起こった1つのエラーに関して、調べたりしても似たようなケースの記事がなかったので紹介したいと思います。
トランザクション、SQLキャッシュで困ってる方やデータベースにあるはずのレコードが何故か見つからないという状況の方は参考になるかもしれないです!

エラーが起きた背景

APIで行っている処理

以下のコードはエラーが起きたエンドポイントで行っている処理を一部抜粋したものです。
(※ 以下のコードは実際のものとは異なります。また、無駄に思えるような処理がありますが、説明のため今回起こったエラーと関係しているものだけを載せています。)

return if User.find_by(params)

ApplicationRecord.transaction do
  User.create!(params)
rescue ActiveRecord::RecordNotUnique
  # ほぼ同時にリクエストが来た場合は別リクエストで対象ユーザが作られているはずなのでそのレコード取得する
  User.find_by!(params)
end

リクエストパラメータで渡された条件と一致するUserが見つかればプログラムを終了し、見つからない場合は渡されたパラメータのUserを新しく作成するという処理です。

どのようなエラーだったのか?

エラーが起きた時の状況を時系列順にご説明します。

  1. 上記のエンドポイントに対して、連続して2つの同じリクエスト(Request1, 2)が来ます(フォームの送信ボタンをダブルクリックなど)
    → ただ、Railsサーバーを複数台で運用しているため、2つのリクエストは別プロセスで並行処理されています
  2. Request1は、リクエストパラメータで渡された条件と一致するUserAをDBから取得しようとする
    → (まだ、UserAのレコードはないので、)見つかりません
  3. (Request1のUserAの作成が行われる前に)Request2で、同様にUserAを取得しようとします
    → (UserAがまだ作成されていないので、)見つかりません
  4. ここでRequest1のプロセスがUserAを作成します
    → Request1のプログラムはここで正常に終了します
  5. Request2でも同様にUserAを作成しようとしますが、既にRequest1が追加したので作れません(Record Unique Error)
  6. UserAの作成に失敗したので、再度UserAをDBから取得しようとします
    → 当然見つかると思いますが、ここでなぜかUserAが見つかりません(Record Not Found)

エラーの原因

仮説

ローカルで今回のエラーケースを再現するのは難しいので、エラーの原因を考えてみます。
エラーが起きた状況を見てみると、データベースにUserAが作成されているのは確実なので(調査でデータベースに直接確認したところUserAのレコードはしっかり作成されていました)、問題のUserAを取得するUser.find_byは、データベースに直接アクセスしていない?、もしかするとSQLキャッシュを使っているのではと考えられます。

また、SQLキャッシュの公式ドキュメントを見るとこのように書いてあります。

データベースに対して同じクエリが再度実行されると、実際にはデータベースにアクセスしません。1回目のクエリでは、結果をメモリ上のクエリキャッシュに保存し、2回目のクエリではメモリから結果を読み出します。
ただし、次の点にご注意ください。クエリキャッシュはアクションの開始時に作成され、アクションの終了時に破棄されます。

エラーが起きた該当クエリ(UserAを取得するUser.find_by)は上記のように2回目のクエリであり、またアクション内で行われているので、原因としてキャッシュは可能性がありそうです。

ただ、ここで1つの疑問が浮かびました。2つのfind_byメソッドの間にCreate文がある場合、1つ目のfind_byクエリのキャッシュは消えるのではないかと思いました。なぜなら、2つのfind_byの間にUserが作成されている可能性があるからです。

検証

それでは上の疑問点も含めて、実際にキャッシュされるのかローカルで検証していきます。
(※ UserのCreateはエラーケース同様Unique ErrorでRollbackするようにしています。)

  1. まずは疑問点を確かめるため、2つのfind_byクエリの間にCreate文を挟むとキャッシュが消えるのか検証

    ソースコード

    User.find_by(uid: '001')
    User.create(uid: '001')
    User.find_by(uid: '001')
    

    結果

    2回目のfind_byでキャッシュをつかっていないことがわかります。
    つまり、予想通り1回目のfind_byのキャッシュがCreate文によってキャッシュが消えたことになります。
    あれ?そうすると、今回問題になっているコードもキャッシュを使っていない?と思いましたが、ひとまず実際のコードでも検証してみます。

  2. 実際のコードでキャッシュを使っているか検証

    ソースコード

    User.find_by(uid: '001')
    
    ApplicationRecord.transaction do
      User.create!(uid: '001')
    rescue ActiveRecord::RecordNotUnique
      User.find_by!(uid: '001')
    end
    

    結果

    なんとキャッシュを使っていました!
    トランザクションをつけた場合は、Create文でキャッシュは消えないということが分かります。
    考えてみればトランザクションはすべての処理が完了するまでCOMMITされないので、トランザクション内ではデータベースのレコードに変更が加わる可能性がありません。なので、Create文が走ってもキャッシュを消す必要がないということかもしれません。
    この挙動の違いはRailsのキャッシュ機構を実装しているソースコードを追いながら後日確かめていきたいです。

結論

以上の検証から、トランザクション内でレコード作成に失敗した場合は、1回目のfind_byのキャッシュは消されず2回目のクエリでキャッシュを使ってしまうことが分かりました。
つまり、今回のエラーは、2つのfind_byクエリの間に並行処理でレコードが作成されていたので、2回目のfind_byで1回目のキャッシュを使い、Not Found Errorが出ていたということでした。

解決策

エラーの原因がわかったので、最後に解決策を探します。
問題はキャッシュ値を使ってしまうということにあったので、キャッシュされない or キャッシュ値を使わないようにする方法を調べてみました。
調べた結果、以下の手段が取れるそうです。

  1. クエリを強制的に発行

    ActiveRecord::Base.connection.execute
    
  2. クエリのキャッシュを消す

    ActiveRecord::Base.connection.clear_query_cache
    
  3. 特定(ブロック内)のクエリだけキャッシュを無効化

    Model.uncached do
    end
    

    このメソッドはキャッシュの無効化であるため、ブロック内のクエリはキャッシュを使用しない、かつ、キャッシュしません。
    ref : https://github.com/rails/rails/blob/f95c0b7e96eb36bc3efc0c5beffbb9e84ea664e4/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb#L101

私たちの場合、キャッシュを使ってしまいそうな部分が多そうかつ可読性が下がるなど懸念点があったので1の方法は除きました。
2の方法も、本件の問題と関係ないキャッシュまで消したくない、また今回はfind_byクエリが2つでしたが3つ以上あった場合、1度キャッシュを消したとしても、3つ目以降のクエリで結局キャッシュを使ってしまい保守性が下がるため除きました。
そして、可読性、保守性を高く保ちつつ、キャッシュ無効化によるパフォーマンスの影響を最小限にするために、キャッシュ無効化の範囲が最も小さくなる3を採用しました。

return if User.find_by(params)
User.uncached do
  ApplicationRecord.transaction do
    User.create!(params)
  rescue ActiveRecord::RecordNotUnique
    # ほぼ同時にリクエストが来た場合は別リクエストで対象ユーザが作られているはずなのでそのレコード取得する
    User.find_by!(params)
  end
end

今回のエラーはローカルで再現することも難しいということもあり、本番環境にリリースするまで原因がキャッシュであることが確証できませんでしたが、無事リリース後からはエラーが起きなくなりました!

まとめ

ニッチな内容に見えて、トランザクションやRailsサーバーを複数台で運用しているケースは多いと思うので今回のようなケースに出会うことも少なくはないのかなと思いました。
少しでも参考になれば嬉しいです!


マネーフォワードでは、エンジニアを募集しています。
ご応募お待ちしています。

【サイトのご案内】
マネーフォワード採用サイト
Wantedly
京都開発拠点

【プロダクトのご紹介】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

Pocket