複数行 UPDATE で発生するデッドロックを回避する

こんにちは、masm11 です。

今回は、RDBMS で複数行の UPDATE すると割と簡単に起きるデッドロックを回避する方法を探ります。 Rails を用い、RDBMS としては PostgreSQL を使います。

準備

まずは、Rails のプロジェクトを作ります。

rails new proj --skip-bundle
cd proj
sed -i -e 's/sqlite3/pg/g' Gemfile    # sqlite3 を pg に書き換え
bundle install --path=vendor/bundle

PostgreSQL にも DB とユーザを作成します。

createdb -U postgres proj
createuser -U postgres proj

config/database.yml は以下のように設定しました。

default: &default
  adapter: postgresql
  user: proj
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  <<: *default
  database: proj

では、モデルを作ります。

bin/rails g model SampleTable col1   

マイグレーション時にテスト用データも作成してしまいます。

class CreateSampleTables < ActiveRecord::Migration[5.2]
  def change
    create_table :sample_tables do |t|
      t.string :col1

      t.timestamps
    end
    SampleTable.create!([ { col1: '123' }, { col1: '456' } ])
  end
end

マイグレーションします。

bin/rails db:migrate

DB 上のデータを確認してみましょう。

luna:proj % psql -U proj -c 'select * from sample_tables' proj
 id | col1 |         created_at         |         updated_at         
----+------+----------------------------+----------------------------
  1 | 123  | 2020-03-11 07:31:53.594273 | 2020-03-11 07:31:53.594273
  2 | 456  | 2020-03-11 07:31:53.596021 | 2020-03-11 07:31:53.596021
(2 行)

luna:proj % 

ちゃんとできていますね。

デッドロックさせる

さて、次にタスクを作成します。

lib/tasks/sample_task.rb を以下のように作成しました。

class Tasks::SampleTask

  def self.execute

    loop do
      SampleTable.update_all({col1: 'foo'})
      $stdout.print '.'
    end

  end

end

これを読み込ませるため、config/application.rb に以下の設定を追加しました。

    config.autoload_paths += ["#{config.root}/lib"]

実行します。

luna:proj % bin/rails runner Tasks::SampleTask.execute
..............................................................

ここまでは動いていますね。

ではこれを3枚の端末で同時実行します。

/home/masm/t/proj/vendor/bundle/ruby/2.7.0/gems/activerecord-5.2.4.1/lib/active_record/connection_adapters/postgresql_adapter.rb:611:in `exec_params': PG::TRDeadlockDetected: ERROR:  deadlock detected (ActiveRecord::Deadlocked)
DETAIL:  Process 875364 waits for ShareLock on transaction 178082; blocked by process 875263.
Process 875263 waits for ShareLock on transaction 178081; blocked by process 875364.
HINT:  See server log for query details.
CONTEXT:  while rechecking updated tuple (8,3) in relation "sample_tables"
: UPDATE "sample_tables" SET "col1" = 'foo'

起きました。デッドロックです。

SQL としては、上記にあるように、

UPDATE "sample_tables" SET "col1" = 'foo'

を実行しているだけです。

これは、id が 1の行と2の行を、順不同で書き換えているためです。

  • 1 をロックして書き換えた後に 2を書き換えるためにロックしようとしているプロセス
  • 2 をロックして書き換えた後に 1を書き換えるためにロックしようとしているプロセス

両者が互いに相手を待ってしまうのです。

解決策

これを回避するには、書き換え順序を保証してやる必要があります。 例えば id 順にするのが良いでしょう。

しかし、

      SampleTable.order(:id).update_all({col1: 'foo'})

これでは、

UPDATE "sample_tables" SET "col1" = 'foo' WHERE "sample_tables"."id" IN (SELECT "sample_tables"."id" FROM "sample_tables" ORDER BY "sample_tables"."id" ASC)

となり、SELECT は id 順になるものの、結局 UPDATE 自体は順不同になってしまいます。

SQL としては、

UPDATE "sample_tables" SET "col1" = 'foo' ORDER BY "id" ASC

のようになって欲しいところです。ですが、MySQL では UPDATE に ORDER BY が指定できるそうですが、 PostgreSQL では使えません。

      SampleTable.transaction do
        a = SampleTable.lock.order(:id).update_all({col1: 'foo'})
      end

これだと

BEGIN
UPDATE "sample_tables" SET "col1" = 'foo' WHERE "sample_tables"."id" IN (SELECT "sample_tables"."id" FROM "sample_tables" ORDER BY "sample_tables"."id" ASC)
COMMIT

となってしまいます。.lock が効いていません。サブクエリに FOR UPDATE が付くかと思ったのですが。

      SampleTable.transaction do
        a = SampleTable.lock.order(:id)
        a.pluck(:id)
        a.update_all({col1: 'foo'})
      end

こうすると、SQL は以下のようになります。

BEGIN
SELECT "sample_tables"."id" FROM "sample_tables" ORDER BY "sample_tables"."id" ASC FOR UPDATE
UPDATE "sample_tables" SET "col1" = 'foo' WHERE "sample_tables"."id" IN (SELECT "sample_tables"."id" FROM "sample_tables" ORDER BY "sample_tables"."id" ASC)
COMMIT

id 順にロックして、ロックし終わった後に UPDATE するので、これなら問題ありません。

まとめ

結果として、「ここまでするか?」と思えるようなものになってしまいましたが、PostgreSQL が UPDATE に ORDER BY が使えず、Rails も update_all が悲観的ロックに対応していない現状では、仕方ないのかもしれません。

ではまた!