eager_load の罠と外部キー制約の重要性
下準備
開く
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
を信用するな- インスタンスに保持していないレコードは削除されない
- 不整合が起きても関知しない
- 外部キー制約重要
- 不整合が起きるなら削除できないようになる (最後の砦)
-
件数が多いときは
in_batches.destroy_all
とした方がよい ↩︎
Discussion