🛤️

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

同じ room_id を持っている memberships は 3 件あったのになぜ 2 件残ってしまったのだろう?

まったく気づけず

このようなロジックで削除されないレコードが本番で山盛り発生していた。しかし 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 も使いたくない場合

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