それマグで!

知識はカップより、マグでゆっくり頂きます。 takuya_1stのブログ

習慣に早くから配慮した者は、 おそらく人生の実りも大きい。

sqlite3 を使っていると遭遇するロック・エラー database is locked

sqlite3 を使っていると遭遇するロック・エラー

sqlite3 を使っていると、トランザクションでデータベースがロックされて、書き込みに失敗することがある。phppythonruby でもなんでも良いけど、トランザクションを使ってるときに、ロックが行われてdatabase is locked という無情なエラーになる。

これは、SQLite3のエラーであり、プロセス間でもロックすることがある。

たとえば、次のエラーはruby で再現したものである。

sqlite3/database.rb:281:in `step': database is locked (SQLite3::BusyException)

再現コードを作る

サクッとruby で再現したものである

mkdir lock-test
cd lock-test
##
bundle init
bundle config set --local path 'vendor/bundle'
bundle config set --local disable_shared_gems true
bundle add sqlite3

ソースを書く lock-error-sample.rb

require 'sqlite3'
require 'openssl'
require "thread"

DSN = 'lock-test.db'

def create_table
  db = SQLite3::Database.new(DSN)
  ct = <<~SQL
  DROP TABLE IF EXISTS log;
  CREATE TABLE IF not Exists log (
      id integer primary key autoincrement,
      name string unique
  );
  INSERT INTO  log ( name ) values( 'aaaa' );
  SQL
  db.execute_batch(ct)
end

def insert_test_data
  ## sqlite3/database.rb:281:in `step': database is locked (SQLite3::BusyException)
  db = SQLite3::Database.new(DSN)
  db.transaction
  a = [OpenSSL::Random.random_bytes(12)].pack("m*").strip
  ct = <<~SQL
    INSERT INTO  log ( name ) values( '#{a}' );
  SQL
  db.execute_batch(ct)
  Thread.pass
  sleep 1
  db.commit
end

create_table
threads = []
threads << Thread.new{|t|insert_test_data }
threads << Thread.new{|t|insert_test_data }
# SQLite3::BusyExceptionになる。
threads.each{|t| t.join  }

実行すると。

bundle exec ruby lock-error-sample.rb

エラーになる。

 `step': cannot commit - no transaction is active (SQLite3::SQLException)

デッドロックが起きる。

スレッドを使って、Thread.passするタイミングでロックしたまま、別スレッドに実行を切り替えている。

  db.transaction
  db.execute_batch(ct)
  sleep 1 #   Thread.passが起きる
  db.commit

sleep 時には Thread.passが実行されるので、確実にロックしたまま別のトランザクションに入ることになる。別のトランザクションでもロックしたままなので双方がデッドロックする。これにより、双方でエラーが起きて実行が終了する。

ロックが開放されるのを待つ。

sqlite3 を使う場合は、ロック解放を待つことができる。

db.busy_timeout = 1000

初期値では0らしいので、ロック解放を待たずに、ロック取得に失敗したらイコール即エラーになる。

リトライを入れる。

エラーになる箇所をはロックしたままになる箇所なので。

  db.transaction
  sleep 1.0
  db.execute_batch(ct)
  sleep 1.0
  db.commit

排他ロックが取れるまで、再起動すればいいと思われる。

  db = SQLite3::Database.new(DSN)
  db.busy_timeout = 1000*3
  db.transaction
  begin
    sleep 1.0
    db.execute_batch(ct)
    sleep 1.0
  rescue  SQLite3::BusyException => e
    retry
  ensure
    db.commit
  end

ruby の場合は、retry という便利すぎるエラー処理があるので、処理は楽かもしれない。phpだとwhile でロック状態を認識して再起動しないと駄目だろうね。

解決策1

ロック待ちをしつつリトライもする。

db.busy_timeout = 1000

解決先2

そもそも、トランザクションを使わない。

実行順番が確実であるひつようがないのであれば(たとえばログなど)、念のためのトランザクションなど不要であり、SQLiteの実装に任せれば良いのである。とてもいい加減な解決と思うかもしれないが、案外良い解決方法です。

解決策3

EXCLUSIVE / IMMEDIATE / DEFERRED を変える。

トランザクションに引数を与えればロックレベルを変えられる。

db.transaction :immediate ... 
db.transaction :deffered ... 
db.transaction :exclusive ...

ただ、sqltie のトランザクションEXCLUSIVEで別プロセスからもロックしたりできる。とあるが、sqlite3 sample.db を複数起動してもとくにロックされなかった。

(上下でユニーク違反になるに同一レコードを登録したが、どちらも正常にCommitできてしまった。)

細かいところを調べるのは時間がないので後回しにする。

余計なトランザクションを書かない。

SQLiteをつかう限りにおいてログなど単なる追記に、保険なトランザクションを書かないのが良いかもしれない。

参考資料