SmartHR Tech Blog

SmartHR 開発者ブログ

RailsでUNIQUE制約を遅延実行できるようにしました

SmartHRではRuby on Railsを多くのサービスで採用しています。
そのため、不足している機能や不具合があればrails/railsへコントリビュートすることがあります。

今日は、日々のぽつぽつとしたコントリビュートの中から、Rails 7.1に追加したUNIQUE制約について紹介します。

unique_constraint(UNIQUE制約)

UNIQUE制約(unique_constraint)はRails7.1(執筆時は未リリース)から利用可能になるActiveRecordの新機能です。 rails/rails#46192

PostgreSQLでしか利用できませんが、下記のようにunique_constraintで遅延可能なUNIQUE制約を定義できるようになりました。

# create_table内で使う場合
create_table :items do |t|
  t.integer :position, null: false
  t.unique_constraint [:position], deferrable: :immediate
end

# create_table外で使う場合
add_unique_constraint :items, [:position], deferrable: :immediate

# 既存のINDEXを遅延可能なUNIQUE制約に変更する場合
add_unique_constraint :items, deferrable: :immediate, using_index: "index_items_on_position"

既存のUNIQUE INDEXの挙動

UNIQUE INDEXが貼ってあるカラムを扱うときに、レコードをまとめて更新しようとして制約に引っかかることがあります。
例として、以下のサンプルコードでUNIQUE INDEXがあるpositionカラムを入れ替える #swap_position の実装について考えてみます。

ActiveRecord::Schema.define do
  create_table :items, force: true do |t|
    t.integer :position, null: false
    t.index [:position], unique: true, name: "index_items_on_position"
  end
end

class Item < ActiveRecord::Base
  # @param other [Item]
  def swap_position(other)
    transaction do
      tmp = position
      update!(position: other.position)
      other.update!(position: tmp)
    end
  end
end

item_1 = Item.create!(position: 0)
item_2 = Item.create!(position: 1)
item_1.swap_position(item_2)

UNIQUE INDEXは更新時に即時チェックされるため、上記の実装では update!(position: other.position) を実行した時にpositionが重複してしまって、UNIQUE制約違反の例外が発生してしまいます。

# ERROR:  重複したキ ー値は一意性制約"index_items_on_position"違反となります (PG::UniqueViolation)
# DETAIL:  キー ("position")=(1) はすでに存在します。

困りましたね。
そこで、新たに追加された unique_constraint の出番です。

遅延可能なUNIQUE制約をつくる

新たに追加された unique_constraintを使えば、UNIQUE制約を遅延可能として定義できます。
さっそく、先ほどのUNIQUE INDEXを遅延可能なUNIQUE制約に置き換えてみましょう。

# using_indexには、既存のINDEX名を指定します。
add_unique_constraint :items, deferrable: :deferred, using_index: "index_items_on_position"

これで、先ほどの処理を実行しても例外は発生しなくなりました。

遅延可能なUNIQUE制約とはなにか

PostgreSQLにおけるUNIQUE制約(or UNIQUE INDEX)は、レコードに変更があれば即時チェックされます。
しかし、UNIQUE制約を遅延可能として定義すれば、チェックのタイミングをtransactionの終了時まで遅延させることができるようになります。

先ほどの例では、UNIQUE制約のチェックを遅延させて、チェックのタイミングを即時ではなく、2つの Item#position を更新した後に遅延させたため、制約違反のエラーが発生しなくなりました。

オプションの値について

add_unique_constraint に渡す deferrable: オプションは false:immediate:deferrable の値を渡すことができます。
それぞれ、次のような挙動をします。

  • deferrable: false
    • デフォルト値
    • UNIQUE INDEX と等価なので(※1)即時チェックされます。
    • これを指定するならこの機能を使う意味がないので、 add_index(..., unique: true) を使えば良いと思います
  • deferrable: :immediate
    • 遅延可能となる
    • 初期値では即時チェック
    • SET CONSTRAINTS { ALL | name [, ...] } DEFERRED をtransaction内で実行することで、transaction終了時まで制約のチェックを遅延できる
  • deferrable: :deferred
    • 遅延可能となる
    • 初期値では遅延チェック
    • SET CONSTRAINTS { ALL | name [, ...] } IMMEDIATE をtransaction内で実行することで、制約を即時にチェックできる

UNIQUE INDEX vs UNIQUE制約

言葉が似ていますが、UNIQUE INDEXとUNIQUE制約はSQL上は別のものです。
内部的には両方ともUNIQUE INDEXを使用していますが、遅延可能なUNIQUE INDEXはUNIQUE制約と紐づけないと作れない縛りがあるので、今回のadd_unique_constraint の機能を追加しました。

既存のUNIQUE INDEXをUNIQUE制約に置き換える上で、いくつか気をつけることがあるので列挙します。

  • 内部的にはどちらもUNIQUE INDEXを持つので、検索時のパフォーマンスに影響はない
  • add_unique_constraint(..., using_index: "unique_index_name") を使ってUNIQUE制約をつくる際に、短いがテーブルロックが発生する(※2)
  • ON CONFLICT 句と遅延可能なUNIQUE制約は併用できない(※3)
  • deferrable: :immediateSET CONSTRAINTS ... DEFERRED をするまで遅延されないので、UNIQUE INDEXと挙動が同じような気がするが、実際はまとめて更新するSQLで挙動が異なる
    • Item.update_all("position = position + 1") のように1クエリでまとめて更新するとき
      • UNIQUE INDEXは、1レコードの更新ごとに制約をチェックする(EACH ROW)
      • 遅延可能なUNIQUE制約は、すべて更新してから制約をチェックする(EACH STATEMENT)
    • まとめて更新するときにUNIQUE INDEXが即時チェックするのは、違反があった時に早めに中断することでパフォーマンスを最適化するのが目的。なので、遅延可能なUNIQUE制約の方がパフォーマンスが悪くなる。(※4)

おわりに

add_unique_constraint が追加されたことで、開発中のサービスではいくつかのコードをリファクタリングできそうです。次のRails7.1のリリースが待ち遠しいですね。

PRをレビューしてくださったyahondaさん、アドバイスをいただいたkamipoさん、大変お世話になりました。

特にyahondaさんは、PRのレビューにかなりの時間を割いていただきました。
このPRを出した後で、PostgreSQLの勉強会にも参加して調査してくださっているのをTwitterで観測して、「そこまでしてくださるCommiterいる...?」と感動しました。
レビューの指摘のおかげ様で、見落としていた挙動に気づけたり、PostgreSQLのコードに少しだけ触れることができました。心からとても感謝しています。

注釈

※1遅延しないUNIQUE CONSTRAINTは、クエリ実行前にCREATE INDEXに置き換えて解釈されるようです parse_utilcmd.c
※2コード上に注意書きがあった index.c
※3 UNIQUE制約/EXCLUSION制約ともに遅延可能な制約 + ON CONFLICTはエラーとなる execIndexing.c
※4 https://postgrespro.com/docs/postgresql/12/sql-createtable#id-1.9.3.85.9.4