🛤️

eager_load の罠と外部キー制約の重要性

2024/07/06に公開

下準備

開く
require "active_record"
require "table_format"

ActiveSupport::LogSubscriber.colorize_logging = false
ActiveRecord::Migration.verbose = false

if true
  system("mysql -u root -e 'DROP DATABASE IF EXISTS __test__; CREATE DATABASE __test__'")
  ActiveRecord::Base.establish_connection(adapter: "mysql2", host: "127.0.0.1", database: "__test__", username: "root")
else
  ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
end

ActiveRecord::Schema.define do
  create_table :rooms do |t|
  end
  create_table :memberships do |t|
    t.belongs_to :room # , foreign_key: true
    t.string :user
  end
end

class Room < ActiveRecord::Base
  has_many :memberships, dependent: :destroy
end

class Membership < ActiveRecord::Base
  belongs_to :room
end

# ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDOUT)

問題提起

Room.create! do |e|
  e.memberships.build(user: "alice")
  e.memberships.build(user: "bob")
  e.memberships.build(user: "carol")
end
tp Room
# > |----|
# > | id |
# > |----|
# > |  1 |
# > |----|
tp Membership
# > |----+---------+-------|
# > | id | room_id | user  |
# > |----+---------+-------|
# > |  1 |       1 | alice |
# > |  2 |       1 | bob   |
# > |  3 |       1 | carol |
# > |----+---------+-------|

となっているとき alice が参加している部屋を削除したかったので次のように書いた。[1]

Room.eager_load(:memberships).merge(Membership.where(user: "alice")).destroy_all

すると Room は正しく削除された。

Room.count  # => 0

しかし Membership はなぜか 2 件残っている。

Membership.count                       # => 2
Membership.where.missing(:room).count  # => 2

dependent: :destroy によって room に結びつく memberships は連動して削除されるはずである。にもかかかわらずなぜ残ってしまったのだろう?

まったく気づけず

dependent: :destroy に全幅の信頼を寄せていたため、不整合が起きる余地はないと考えていた。しかし、その結果として、削除されないレコードが本番環境で徐々に蓄積され、それに比例して、クエリの速度も徐々に遅くなっていったことにまったく気づけなかった。

外部キー制約重要

これは次のように外部キー制約を有効にしておけばすぐに気づけた問題だった。

-  t.belongs_to :room
+  t.belongs_to :room, foreign_key: true

このようにしておくと不整合な状態にできなくなる。

暫定的な問題回避

まず room が持っている memberships が 1 件しかないのおかしい。

room = Room.eager_load(:memberships).merge(Membership.where(user: "alice")).sole
room.memberships.size  # => 1

この状態で room を削除するから残りの membership たちが取り残される。したがって、いったんリロードする。

room.memberships.reload

実際はぶら下がるのが memberships だけとは限らない場合があるので根元から、

room.reload

の方がよい。これで個数が正しくなるので、

room.memberships.size  # => 3

正しく削除できる。

room.destroy!
Membership.count  # => 0

根本的に直す

eager_load を joins に変更する。

room = Room.joins(:memberships).merge(Membership.where(user: "alice")).sole
room.memberships.size  # => 3
room.destroy!
Membership.count       # => 0

eager_load は LEFT JOIN なのに対して joins は INNER JOIN なのでうまくいく。

重複する部屋

だからといってなんでも eager_load を joins に置き換えればいいわけではなく、alice と bob のどちらかが含まれる部屋を削除したい場合、

rooms = Room.joins(:memberships).merge(Membership.where(user: ["alice", "bob"]))
rooms.size         # => 2
rooms.destroy_all  # => [#<Room id: 1>, #<Room id: 1>]
Membership.count   # => 0

と、なって部屋は1つしかないはずなのに部屋が2つ出てきて destroy_all では二回分もの削除処理が走る。この無駄を回避するには distinct が必要で、

rooms = Room.joins(:memberships).merge(Membership.where(user: ["alice", "bob"])).distinct
rooms.size         # => 1
rooms.destroy_all  # => [#<Room id: 1>]
Membership.count   # => 0

とする。

distinct を使いたくない場合

distinct が必要になってしまうのは、そもそも結合しすぎなので結合しすぎなければよい。その場合は eager_load を使う。

rooms = Room.eager_load(:memberships).merge(Membership.where(user: ["alice", "bob"]))
rooms.size         # => 1
rooms.destroy_all  # => [#<Room id: 1>]
Membership.count   # => 1

こうして最初のコードに戻ってしまった。そして Membership が正しく削除されない問題も起きている。したがって、

  • joins を使うには distinct も必要だが reload は不要になる
  • eager_load を使うには最後に reload が必要になる

となる。

joins + distinct も eager_load + reload も使いたくない場合

rooms = Room.where(id: Membership.where(user: ["alice", "bob"]).pluck(:room_id))
rooms.size         # => 1
rooms.destroy_all  # => [#<Room id: 1>]
Membership.count   # => 0

これなら間違えようがない。欠点はSQLが超長くなるのと速度的に気持ち程度遅いところ。

外部キー制約不要論を改める

Rails では ActiveRecord が強力なのと、開発環境で削除の順番を気にかける必要があるのが嫌で、外部キー制約にメリットを感じていなかったが、今回の件でやっぱり必要、と考えを改めた。

まとめ

  • eager_load からの即削除は危険
    • 関連するレコードがすべて引けていない場合がある
  • dependent: destroy を信用するな
    • インスタンスに保持していないレコードは削除されない
    • 不整合が起きても関知しない
  • 外部キー制約重要
    • 不整合が起きるなら削除できないようになる (最後の砦)
脚注
  1. 件数が多いときは in_batches.destroy_all とした方がよい ↩︎

Discussion