「タグを外したはずの投稿が、そのタグで検索するとヒットする」という問い合わせを受けたことがあります。タグを付け直しても、外し直しても直らないとのことで、手元で見てもタグの紐付けは確かに0件でした。
調べてみると、原因は検索用に持たせていた非正規化カラムの更新漏れで、その引き金はdelete_allでした。Railsを触っていれば誰でも知っているはずの「delete_allはコールバックを呼ばない」という挙動が、非正規化カラムと組み合わさると意外と気づきにくい不具合になった、という話です。
以下のコード例は実際のプロダクトコードそのものではなく、同じ構造が伝わるように書き起こしたサンプルです。
状況:検索用に値を非正規化していた
投稿(Post)に付けたタグ名で検索できる機能があります。タグは中間テーブル(Tagging)で多対多になっていますが、検索のたびに結合すると遅くなるため、投稿側のカラムにタグ名を連結して持たせていました。
class Post < ApplicationRecord
has_many :taggings
has_many :tags, through: :taggings
# search_tag_text: タグ名を連結した文字列(検索専用)
end
class Tagging < ApplicationRecord
belongs_to :post
belongs_to :tag
after_destroy :refresh_search_tag_text
private
def refresh_search_tag_text
post.update_search_tag_text!
end
end
タグ検索はこのsearch_tag_textだけを見ています。紐付けが消えたときの再計算は、Taggingのafter_destroyに任せる作りでした。
何が起きていたか
タグ自体を削除する処理は、紐付けの削除を次のように一括で済ませていました。
class Tag < ApplicationRecord
has_many :taggings
def discard
taggings.delete_all # 一括削除(SQLのDELETE 1発)
destroy
end
end
delete_allは、レコードをインスタンス化せずにSQLのDELETEを直接発行します。そのためdestroyも呼ばれず、after_destroyなどのコールバックも動きません。Railsのドキュメントにも、delete_allはコールバックを呼び出さないと書かれています。
つまり、タグを削除するとtaggingsの行は消えるのに、投稿側のsearch_tag_textには削除済みのタグ名が残り続けます。検索はこのカラムしか見ないので、「もう付いていないのにヒットする」状態になっていました。
しかも、この古い値は投稿がたまたま保存し直されるまで直りません。削除した直後ではなく、時間が経ってから問い合わせとして表面化するので、因果関係に気づきにくいのも厄介でした。
調べるときに役立った確認方法
「親を保存し直すと直る」なら非正規化カラムを疑う
問い合わせの時点で、投稿を一度保存し直すと検索にヒットしなくなる、ということは分かっていました。保存時には再計算が走るためです。この挙動は、検索の元データではなく非正規化カラムがずれているサインでした。
影響範囲の下限を数える
関連が空なのに非正規化カラムに値が残っているレコードを数えれば、影響範囲の下限が分かります。
Post.where.not(search_tag_text: [nil, ''])
.where.missing(:taggings)
.count
本番で数えたところ、約1万6千件ありました。タグが一部だけ残っている投稿は、この条件では拾えないので、実際はもっと多いはずです。1件の問い合わせの裏に、これだけの数が潜んでいたことになります。
対策
削除の前にidを控えて、削除の後に再計算を予約する
delete_all自体はコールバックを呼ばないので、削除の経路の中で自分で再計算を予約するようにしました。ポイントは、紐付けを消す前に影響を受ける投稿のidを取得しておくことです。消してしまってからでは、どの投稿が影響を受けるのか分からなくなります。
def discard
post_ids = taggings.pluck(:post_id)
taggings.delete_all
destroy
Tag.refresh_search_tag_text_of_posts(post_ids) # 非同期で再計算を予約
end
再計算の予約処理はクラスメソッドにまとめ、コールバック経由の経路と一括削除の経路で同じものを使うようにしました。経路ごとに別々の実装を持つと、片方だけ直し忘れるからです。
削除と再登録を連続して行う処理は、再登録の後にも予約する
タグの統合(あるタグを別のタグにまとめる)では、紐付けを削除してから付け替える、という処理が連続します。削除の時点で予約したジョブが、付け替えが終わる前に走ってしまう可能性があるため、付け替えの後にもう一度予約するようにしました。統合先のタグがすでに付いていた投稿が再計算されていなかったのは、この部分の考慮漏れでした。
既存データは補正用のスクリプトで直す
コードを直しても、すでにずれているデータは自動では直りません。現在の関連から作り直した値と比較して、ずれているものだけを補正するadhocを用意しました。
注意点は実行順序です。必ずデプロイの後に実行します。補正を先にやると、まだ修正前のコードが動いているので、その後の削除でまたずれてしまいます。
手元で確認するときの落とし穴
修正後の動作確認で、ユーザーから一度「直っていない」と言われたことがありました。原因は修正ではなく、ローカルでdelayed_jobが起動していなかったことでした。再計算を非同期にしているため、ジョブが動いていないとタグを削除してもヒットし続けます。bin/rails jobs:workoffで溜まっているジョブを実行してから確認すると、期待どおりに直っていました。
まとめ
delete_allがコールバックを呼ばないことは知っていても、その先に非正規化カラムがぶら下がっていると、影響が遠いところに出るので気づけません。今回学んだことは次のとおりです。
- 値を非正規化して持つなら、その再計算がどの経路で呼ばれるのかを洗い出しておく。
delete_allやupdate_allのような一括操作はコールバックを通らない - 一括削除の中で再計算を予約するなら、削除の前にidを控えておく
- 「親を保存し直すと直る」という症状は、非正規化カラムのずれを疑うヒントになる
- 既存データの補正は、原因の修正をデプロイしてから行う
一括操作はパフォーマンスのために使うことが多いので、コールバックを通らない前提で、必要な後処理を明示的に書くようにしていきたいです。同じような構造のアプリをお持ちの方の参考になれば嬉しいです。