トランザクションをコミットする前に非同期処理を実行しないほうがいいケース


説明用に、主にRailsを使っている場合を例として取りあげます。次のコード

Task.transaction do
  task = Task.create(task_params)
  NotificationJob.perform_later(task)
end

は、ときどき以下のようなエラーを発生させることがあります。

ActiveJob::DeserializationError: Error while trying to deserialize arguments: Couldn't find Task with 'id'=1

DBからデータをオブジェクトとしてデシリアライズしようとして失敗しています。この原因を説明します。

まず、RDBではデフォルトのトランザクション分離レベルがread committedかそれより厳密であることが多いです1。read committedの場合、あるトランザクションでコミットされたデータは別のトランザクションからも読み取ることができます。

また、Task のレコードは Task.transaction のブロックを抜けるまではコミット済みになりません。このとき、トランザクション内でActive Jobの perform_later を実行すると、キューにジョブが入り、ワーカーがキューからジョブを取り出して処理します。

ワーカーがジョブを取り出すタイミングによっては、Task のレコードを保存するトランザクションがコミットされる前に非同期処理が実行されることがありえます。これが起きると、非同期処理はデータを保存しようといているトランザクションとは別のトランザクションとなり、また、分離レベルがread committed以上であることから、まだDBから読み取ることができないデータをしばしばオブジェクトとしてデシリアライズしようとし、結果的にデータが見つからずに失敗となります。

次のような処理の構造にすると、この問題を回避できます。

# トランザクション内ではレコードの保存だけ実行する
task = Task.transaction do
  Task.create(task_params)
end

# 確実にコミット済みのデータを非同期処理の中で読み取る
NotificationJob.perform_later(task)

  1. PostgreSQLではread committed、MySQL (InnoDB) では、もう一段階厳密な分離レベルであるrepeatable readがデフォルト