意図せず関連先のカラムでwhereしつつeager loadしたらクエリのパフォーマンスが極端に悪化した事例

問題

15個ぐらいのさまざまなクエリパラメータを検索条件として受け付けることができる一覧取得API(「Item取得API」とする)があった。

そのAPIで取得するItemは複数の関連を持っていた。また、関連先の取得時にN+1問題の対策が不十分だったので、取得するデータで必要な関連先を漏れなくincludesでeager loadした結果、ほとんどのケースでパフォーマンスが改善していた。

しかし、あるクエリパラメータ(qとする)を使うときだけ極端にAPIのパフォーマンスが悪化するという現象が見られた。

先に結論

よく言われていることですが、関連先の取得方法が複雑になりそうなら、preload/eager_loadで読み込み方法を明示的に指定して、意図したクエリを作るようにしましょう。

原因

元々のコードは次のようなイメージ*1:

# ItemsController
def index
  relation = current_user.items

  # クエリパラメータに基づくたくさんのwhere ...
  if params[:q]
    relation = relation.where(genre: { q: params[:q] })
  end
  # さらに続く...

  # 関連先を漏れなくincludes
  relation = relation.includes(
    :genre,
    :sub_items,
    user: :store,
    category: {
      user: :store,
      :sub_items
    }
  )

  # JSONにしてrenderして終わり
end

qは関連先テーブルのカラムをWHEREの条件として用いるときのパラメータ。そのパラメータをAPIに渡してwhereで絞り込みをかけるときだけ、関連先テーブルのカラムでWHEREすることになる。

このとき、Item取得と同時に関連先のテーブルの情報で絞り込みをかける必要があるので、includesはLEFT JOINを発行するeager_loadの挙動となる。すると、includesは指定したすべての関連先を(たとえ他の関連先テーブルのカラムでWHEREするわけではなくても)LEFT JOINで読み込むことになる*2*3

関連先のテーブルすべてをLEFT JOINすると、テーブルの構造が原因で本来取りたいデータセットの行数の3乗程度の数のデータを取得しようとして、DB側でメモリを使い尽くして*4エラーになっていた。

もう少し詳しい経緯

同じ関連先を複数回includesの引数としていた

モデルItemの関連先どうしも関連を持っていた。たとえば、Itemが持つsub_itemscategoryについて、それらどうしやuserへ関連を持つイメージ。これにより、includesの引数に同じ関連先が複数回出てくることがあった:

relation.includes(
  :genre,
  :sub_items, # 1回目
  user: :store, # 1回目
  category: {
    :sub_items # 2回目
    user: :store, # 2回目
  }
)

例えばuserstoreは関連元Itemのメソッドでuser.store.foo?のようにアクセスされるのに加えて、Category#barでもuser.store.bar?のようにアクセスされていた。このとき、たとえばcategoryのネストした関連先指定を省くと、Category#barの呼び出しが複数回あるときに都度クエリが発行されてしまうので、上のように書かざるをえない*5

同じテーブルが複数回LEFT JOINされていた

上記のように関連先を指定していてeager_loadを使うとき、includesへの指定の仕方が2通りあることから、sub_itemsは2通りの結合条件でLEFT JOINされる。細かい点は説明しないが、テーブルの構成的に、このように2回LEFT JOINすると本来取りたい行数の2乗の数データが取得されてしまっていた*6。実際の事例ではもう少し込み入った感じになっていて、実際に取りたい行数の3乗程度の行数を取得しようとしていた。

解決法

解決法は簡単で、問題となったクエリパラメータを使うテーブル以外の関連先テーブルはつねにpreloadを明示的に指定するだけ。qでの絞り込み用のカラムを持つ関連先テーブルはincludesのままにする。

def index
  relation = current_user.items

  # ...
  if params['q']
    relation = relation.where(genre: { q: params['q'] })
  end
  # ...

  # whereで使わない関連先を漏れなくpreload
  relation = relation.preload(
    :sub_items,
    user: :store,
    category: {
      user: :store,
      :sub_items
    }
  )

  # whereで使われるかもしれないのでincludes
  relation = relation.includes(:genre)

  # JSONにしてrenderして終わり
end

問題のパラメータを指定しなければpreloadで読み込んでくれるし、必要なときはeager_loadになる。

今回は、テストの規模ではそこまで遅くなることに気付けなかったのと、問題のパラメータ指定時のクエリを見ていなかったのが敗因。単純なケースならincludesのほうが考えることが少なくて便利だが、関連先の取得方法が複雑になってきたら横着せずにpreloadや(今回は使わなかったが)eager_loadで関連先の読み込み方法を明示的に指定したほうがよい。

*1:出てくるモデルや関連はすべて仮のもの

*2:https://github.com/rails/rails/blob/7b5cc5a5dfcf38522be0a4b5daa97c5b2ba26c20/activerecord/lib/active_record/relation/finder_methods.rb#L379

*3:ちなみに他のクエリパラメータだけ使うときは、関連元テーブルitemsのカラムでWHEREしており、関連先テーブルは複数のクエリで取得するpreloadの挙動になっていた

*4:数十万〜の行数を一発で取ろうとしていた

*5:必ずItemの関連から引くようにするなどより大きい範囲でコードを改善をするのがベターだが、いまはそれができないという仮定

*6:いわゆるproductとproduct variantとoptionのような構成でproductとproduct variantそれぞれにoptionをLEFT JOINしたのに近い cf. https://shopify.dev/docs/admin-api/rest/reference/products/product-variant

リソースとActive Recordのモデルのあいだの差異を吸収するクラスを作る

Web APIのリソースとバックエンドで扱うモデル(特にActive Recordのモデル)に歴史的な事情で差異があり、単純にモデルからリソースへと変換できないとき、それらの差異を吸収するクラスを作って対応することがあったのでメモを残しておきます。


問題

あるWeb APIに、親リソース*1authorが1:Nで持つ子リソースbookの一覧だけを返すエンドポイントGET /booksを追加しようとしていた*2。追加するエンドポイントが返すリソースの例を示す:

{
  "books": [
    {
      "id": 1
      "author_id": 1,
      "title": "Bar",
      "publisher": "Baz, Inc.",
      "released_at": "2020-01-01"
    },
    // ...
  ]
}

なお、従来から、親リソースGET /authorsを取得するときにはbooksも取得はできていたとする。また、既存のAPIリソースはActive Model Serializer (AMS)で生成しているとする。

このエンドポイントで返すリソースが持つフィールド、リソースに対応するActive Record (AR)のモデルの属性、データベース上の対応するテーブルのカラムには、それぞれ微妙にズレが見られた。具体的には次のような問題があった:

  1. ARのモデルが持っている属性の一部だけがリソースの属性となる
  2. テーブルのカラム名とリソースのフィールド名/モデルの属性名が異なる
  3. APIリソースのフィールド名とARモデルのリレーション名が被っている

まず、ARのモデルが持っている属性、すなわち対応するテーブルが持っているすべてのカラムのうち一部だけがリソースの属性となっていた。この場合、リソースを表すJSONに変換するときにフィルタする必要がある。

create_table :books do |t|
  t.column :title, :string
  t.column :release_date, :date
  t.column :memo, :string # リソースには含めない
  # ...
end

また、テーブルのカラム名をよりわかりやすくするために、その後に作られたモデルやAMSのシリアライザでは改名された属性名を使っているケースがあった。

class Book < ApplicationRecord
  alias_attribute :title, :name
end

class BookSerializer < ActiveModel::Serializer
  def released_at
    object.release_date
  end
end

さらに、特殊な事例だが、ARのモデルではリレーションとして定義されている名前が、AMSで生成されるリソースではスカラーな値として提供されているケースがあった。

class Book < ApplicationRecord
  belongs_to :publisher
end

class Publisher < ApplicationRecord
  has_many :books
end

class BookSerializer < ActiveModel::Serializer
  attributes :publisher

  def publisher
    object.publisher.name
  end
end

後述するが、このケースでAMSを使わないようにする場合、リソース用に加工したフィールドの値を作る必要がある。しかし、今回はリレーションとしてそのフィールドと同名のメソッドが存在するので、メソッド名に気をつけないと、そのリレーションを使っている他のコードが壊れてしまってうまくいかない。

今回は、既存のコードをできるだけ変えずにこれらの問題に対処しながら、エンドポイントGET /booksを追加したいとする。

リソースとモデルの間の差異を吸収する層を導入する

問題を解決するために、リソースとモデルの間の差異を吸収する層を導入する。今回はAMSを使わず実現した*3。POROにActive Modelを組み合わせて使う。

次のようにリソースのフィールドとモデルの属性のマッピングを持たせたクラスを書く。リソースだけで使うメソッドはモデルに書く。

class Book < ApplicationRecord
  # ...

  concerning :Api do
    def publisher_name
      publisher.name
    end
  end
end

class Book::ListResource
  include ActiveModel::Model

  attr_accesor :books

  ATTRS_AND_METHODS = {
    id: :id,
    author_id: :author_id,
    title: :title,
    publisher: :publisher_name,
    released_at: :release_date
  }

  def build
    books.map do |book|
      ATTRS_AND_METHODS.map { |attr, method|
        [attr, book.send(method)]
      }.to_h
    end
  end
end

次のように使う。

# コントローラ内での利用例
@books = Book::ListResource.new(
  books: Book.order(:id).limit(20)
)

# JBuiderのテンプレート
json.books @books

感想

  • pros
    • 既存の実装に影響を与えず、コントローラからもシンプルにリソースを生成できた
  • cons
    • ATTRS_AND_METHODSのようなマッピングを書かないといけない
    • 新しい層を導入するので設計意図をなんらかの手段で伝える必要がある

*1:リソースは「APIが返すデータの構造」ぐらいの意味合いとする

*2:これはあくまでも例

*3:内部事情

外部サービスのリソースをJSONシリアライザブルなActive Modelとして表現する

RailsでWeb APIを作っていて、外部のサービスからリソースを取得し、DBには保存しないもののレスポンスに含めてクライアントに返したい、ということがありました。このとき次のモジュールが役立ったので紹介します。

モジュールの説明

ActiveModel::Model はActive Recordとも互換性のあるインタフェースでRails上のモデルとして扱える機能がいろいろ入るモジュールです。ActiveModel::AttributesAttributes APIを提供するモジュールです。最後の ActiveModel::Serializers::JSON は有名なActiveModelSerializers gemと名前は似ているのですが別物の、rails/railsactivemodel gem内に含まれるモジュールです。

実現方法

POROに ActiveModel::Serializers::JSON をincludeして render :json に渡すと、Attributes APIの attribute で定義した属性からなるJSONをシリアライズできるようになります。これを利用して、次のようなクラスを作りました。

まず、外部サービスから得られるリソースを表すクラスを作り、上述したモジュールをincludeします。

class ExternalServiceResource
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON
end

次に、ExternalServiceResource をもとに具体的なリソースを表すクラスを作ります。ここでは外部ブログサービスから記事リソースを取得しているとします。

class Article < ExternalServiceResource
  attribute :title, :string
  attribute :content, :string
  attribute :status, :integer
  attribute :created_at, :datetime

  DRAFT = 0
  PUBLIC = 1
  DELETED = 2

  def unavailable?
    status.in?([DRAFT, DELETED])
  end
end

class Blog
  # ...

  def articles
    raw_response = client.articles
    raw_articles = JSON.parse(raw_response)['articles']

    raw_articles
      .map { |raw_article| Article.new(raw_article) } # 生JSONをモデルオブジェクトへ変換
      .reject(&:unavailable?)
  end

  private

  def client
    # 外部サービスのWeb APIを叩いてJSONを返すクライアント
  end
end

これらを使うと、外部サービスのリソースも含んだJSONを返すエンドポイント自体は、シンプルに実装できます。

class ArticlesController < ActionController::API
  # articles#index で外部ブログ記事のうちpublicなものを取得できる
  def index
    render json: { articles: blog.articles }
  end

  private

  def blog
    # Blogのインスタンスを返す
  end
end

pros/cons

この方法の利点は、サードパーティgemのActiveModelSerializersを使うのに比べると、Railsのモジュールだけで実現できてシンプルというところや、モデルとして外部リソースを表現できるので、そのリソースに関するロジックをモデルに置くことができるという点が挙げられます。

逆に、欠点として、自前実装になるのでうまくやらないと負債になりそうな点や、リソースの構造がネストするときに、Attributes APIでは ActiveModel::Type.register を使って自前の型を定義していくと実装がやや面倒という点です。ただし、ネストしているリソースについて型を定義せず、次のように型を指定せずに属性を定義することもできるにはできます。

# Article#author に入れるためのモデル
class Author < ExternalServiceResource
  attribute :name
  # ...
end

class Article < ExternalServiceResource
  attribute :title, :string
  attribute :content, :string
  attribute :status, :integer
  attribute :created_at, :datetime

  # Authorのオブジェクトを入れるつもりだが、型指定はとくになし
  attribute :author

  # ...
end

Active Recordでstring型属性を暗号化するためのRailsプラグインを作った

複数プロジェクトで、Active Recordのstring型を拡張して透過的に文字列を暗号化/復号できる型をattributes API(ActiveRecord::Attributes) を使って書く場面を目撃したり、自分でも書く機会があったので、Railsプラグインに切り出してみました。

github.com

使い方は次のとおりです。

# users.tokenはstring型
class User < ActiveRecord::Base
  attribute :token, :encrypted_string
end

# 環境変数かconfigで設定する
ENV['ENCRYPTED_STRING_PASSWORD'] = 'password'
ENV['ENCRYPTED_STRING_SALT'] = SecureRandom.random_bytes
# ActiveRecord::Type::EncryptedString.encryption_password = 'password'
# ActiveRecord::Type::EncryptedString.encryption_salt = SecureRandom.random_bytes

# DB内では暗号化されているがオブジェクト経由で取り出すと復号されている
user = User.create(token: 'token_to_encrypt')
ActiveRecord::Base.connection.select_value('SELECT token FROM users') #=> "eVZzbUlXME1xSlZ5ZWZPQnIvY..."
user.token #=> "token_to_encrypt"

内部的には ActiveSupport::MessageEncryptor を使う、よくある実装になっています。gemにすることですぐに使えるようになって便利ですね。

OpenAPI 3ドキュメントも使えるSchemaConformist 0.3.0をリリースした

rubygems.org

これまでのバージョンの差分はOpenAPI 3ドキュメントが使えるようになった点です。OpenAPI 3に対応したCommittee v3の機能を使うことで、integration test/request spec実行中にOpenAPI 3ドキュメント中のスキーマに基づいたJSONレスポンスのバリデーションを自動実行できるようになりました。

OpenAPI 3についてはRubyKaigi 2019での@ota42yさんの発表資料や、WEB+DB PRESS Vol.108の特集1が参考になります。

speakerdeck.com

gihyo.jp

以上の対応に伴って、Committeeの機能を利用して自動でドキュメントフォーマットが判別できるようになったので、オプション driver の指定は不要になりました。また、オプション schema_path については必須とすることにしました。これは、schema_path を指定しなかったときにデフォルトで入るパス(public/swagger.json など)がとくに一般的なものではないことと、driver をオプションとして渡さなくなったことによります。

他には、ignored_api_paths は正規表現だけでなくふつうのStringも渡せるようにしました。渡された文字列と前方一致するパスを検索して、そのパスで表されるエンドポイントについてはテスト実行時にバリデーションをスキップします。

もし仮に使っている人がいれば、なにかおかしいところがあったら教えてください。