コンテンツへスキップ

トランザクション

データベースを操作する場合、操作をグループ化して、1つが失敗した場合に最新の安全な状態に戻ることができるようにする必要があることがよくあります。この解決策は、**トランザクションパラダイム**で説明されており、ACIDプロパティ(原子性、一貫性、独立性、耐久性)[^ACID]を満たすために、ほとんどのデータベースエンジンによって実装されています。

これを踏まえ、次の例を示します。

2つの口座(それぞれ名前と金額で表されます)があります。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100

ある時点で、ある口座から別の口座に送金が行われます。たとえば、*JohnがSarahに50ドルを送金する*という場合です。

2つの口座(それぞれ名前と金額で表されます)があります。

deposit db, "Sarah", 50
withdraw db, "John", 50

いずれかの操作が失敗した場合、最終状態が一貫性のないものになる可能性があることに注意することが重要です。そのため、**2つの操作**(預金と引き出し)を**1つの操作**として実行する必要があります。そして、エラーが発生した場合、その1つの操作が実行されなかったかのように時間を巻き戻したいと考えています。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100

db.transaction do |tx|
  cnn = tx.connection

  transfer_amount = 1000
  deposit cnn, "Sarah", transfer_amount
  withdraw cnn, "John", transfer_amount
end

上記の例では、Database#transactionメソッドを呼び出すだけでトランザクションを開始します(databaseオブジェクトの取得方法はget_bank_dbメソッドにカプセル化されており、このドキュメントの範囲外です)。blockはトランザクションの本体です。blockが(エラーなしで)実行されると、データベースに変更を永続化するために**暗黙的なコミット**が最終的に実行されます。いずれかの操作によって例外がスローされると、**暗黙的なロールバック**が実行され、データベースがトランザクション開始前の状態に戻ります。

例外処理とロールバック

前述のように、例外がスローされると**暗黙的なロールバック**が実行されます。そして、その例外は私たちによってレスキューされる可能性があることに言及する価値があります。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100

begin
  db.transaction do |tx|
    cnn = tx.connection

    transfer_amount = 1000
    deposit(cnn, "Sarah", transfer_amount)
    # John does not have enough money in his account!
    withdraw(cnn, "John", transfer_amount)
  end
rescue ex
  puts "Transfer has been rolled back due to: #{ex}"
end

トランザクションの本体内で例外を発生させることもできます。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100

begin
  db.transaction do |tx|
    cnn = tx.connection

    transfer_amount = 50
    deposit(cnn, "Sarah", transfer_amount)
    withdraw(cnn, "John", transfer_amount)
    raise Exception.new "Because ..."
  end
rescue ex
  puts "Transfer has been rolled back due to: #{ex}"
end

前の例と同様に、例外によりトランザクションがロールバックされ、その後私たちによってレスキューされます。

動作が異なるexceptionが1つあります。DB::Rollbackがブロック内で発生した場合、暗黙的なロールバックは行われますが、例外はブロックの外では発生しません。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100

begin
  db.transaction do |tx|
    cnn = tx.connection

    transfer_amount = 50
    deposit(cnn, "Sarah", transfer_amount)
    withdraw(cnn, "John", transfer_amount)

    # rollback exception
    raise DB::Rollback.new
  end
rescue ex
  # ex is never a DB::Rollback
end

明示的なコミットとロールバック

これまでのすべての例では、ロールバックは**暗黙的**ですが、トランザクションにロールバックするように指示することもできます。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100

begin
  db.transaction do |tx|
    cnn = tx.connection

    transfer_amount = 50
    deposit(cnn, "Sarah", transfer_amount)
    withdraw(cnn, "John", transfer_amount)

    tx.rollback

    puts "Rolling Back the changes!"
  end
rescue ex
  # Notice that no exception is used in this case.
end

そして、commitメソッドを使用することもできます。

db = get_bank_db

db.transaction do |tx|
  cnn = tx.connection

  transfer_amount = 50
  deposit(cnn, "Sarah", transfer_amount)
  withdraw(cnn, "John", transfer_amount)

  tx.commit
end

注記

commitまたはrollbackが使用された後、トランザクションは使用できなくなります。接続は開いたままですが、ステートメントは終了したトランザクションのコンテキスト外で実行されます。

ネストされたトランザクション

名前が示すように、ネストされたトランザクションは、別のトランザクションのスコープ内で作成されたトランザクションです。ここに例を示します。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100
create_account db, "Jack", amount: 0

begin
  db.transaction do |outer_tx|
    outer_cnn = outer_tx.connection

    transfer_amount = 50
    deposit(outer_cnn, "Sarah", transfer_amount)
    withdraw(outer_cnn, "John", transfer_amount)

    outer_tx.transaction do |inner_tx|
      inner_cnn = inner_tx.connection

      # John => 50 (pending commit)
      # Sarah => 150 (pending commit)
      # Jack => 0

      another_transfer_amount = 150
      deposit(inner_cnn, "Jack", another_transfer_amount)
      withdraw(inner_cnn, "Sarah", another_transfer_amount)
    end
  end
rescue ex
  puts "Exception raised due to: #{ex}"
end

上記の例からのいくつかの観察結果:inner_txは、outer_txがコミットを保留しているにもかかわらず、更新された値で動作します。outer_txinner_txで使用される接続は、**同じ接続**です。これは、inner_txが作成時にouter_txから接続を継承するためです。

ネストされたトランザクションのロールバック

すでに見たように、ロールバックはいつでも(例外によって、またはrollbackメッセージを明示的に送信することによって)実行できます。

そのため、**外部トランザクションに配置された例外によって実行されるロールバック**の例を示します。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100
create_account db, "Jack", amount: 0

begin
  db.transaction do |outer_tx|
    outer_cnn = outer_tx.connection

    transfer_amount = 50
    deposit(outer_cnn, "Sarah", transfer_amount)
    withdraw(outer_cnn, "John", transfer_amount)

    outer_tx.transaction do |inner_tx|
      inner_cnn = inner_tx.connection

      # John => 50 (pending commit)
      # Sarah => 150 (pending commit)
      # Jack => 0

      another_transfer_amount = 150
      deposit(inner_cnn, "Jack", another_transfer_amount)
      withdraw(inner_cnn, "Sarah", another_transfer_amount)
    end

    raise Exception.new("Rollback all the things!")
  end
rescue ex
  puts "Exception raised due to: #{ex}"
end

outer_txブロックで行われたロールバックは、inner_txブロック内のものも含め、すべての変更をロールバックしました(**明示的な**ロールバックを使用した場合も同じです)。

**内部トランザクションブロックの例外によってロールバックが実行された場合**、outer_tx内のものも含め、すべての変更がロールバックされます。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100
create_account db, "Jack", amount: 0

begin
  db.transaction do |outer_tx|
    outer_cnn = outer_tx.connection

    transfer_amount = 50
    deposit(outer_cnn, "Sarah", transfer_amount)
    withdraw(outer_cnn, "John", transfer_amount)

    outer_tx.transaction do |inner_tx|
      inner_cnn = inner_tx.connection

      # John => 50 (pending commit)
      # Sarah => 150 (pending commit)
      # Jack => 0

      another_transfer_amount = 150
      deposit(inner_cnn, "Jack", another_transfer_amount)
      withdraw(inner_cnn, "Sarah", another_transfer_amount)

      raise Exception.new("Rollback all the things!")
    end
  end
rescue ex
  puts "Exception raised due to: #{ex}"
end

inner-transactionの変更をロールバックしながら、outer-transactionの変更を保持する方法があります。inner_txオブジェクトでrollbackを使用します。これにより、**内部トランザクションのみ**がロールバックされます。ここに例を示します。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100
create_account db, "Jack", amount: 0

begin
  db.transaction do |outer_tx|
    outer_cnn = outer_tx.connection

    transfer_amount = 50
    deposit(outer_cnn, "Sarah", transfer_amount)
    withdraw(outer_cnn, "John", transfer_amount)

    outer_tx.transaction do |inner_tx|
      inner_cnn = inner_tx.connection

      # John => 50 (pending commit)
      # Sarah => 150 (pending commit)
      # Jack => 0

      another_transfer_amount = 150
      deposit(inner_cnn, "Jack", another_transfer_amount)
      withdraw(inner_cnn, "Sarah", another_transfer_amount)

      inner_tx.rollback
    end
  end
rescue ex
  puts "Exception raised due to: #{ex}"
end

inner-transactionブロックでDB::Rollback例外が発生した場合も同じです。

db = get_bank_db

create_account db, "John", amount: 100
create_account db, "Sarah", amount: 100
create_account db, "Jack", amount: 0

begin
  db.transaction do |outer_tx|
    outer_cnn = outer_tx.connection

    transfer_amount = 50
    deposit(outer_cnn, "Sarah", transfer_amount)
    withdraw(outer_cnn, "John", transfer_amount)

    outer_tx.transaction do |inner_tx|
      inner_cnn = inner_tx.connection

      # John => 50 (pending commit)
      # Sarah => 150 (pending commit)
      # Jack => 0

      another_transfer_amount = 150
      deposit(inner_cnn, "Jack", another_transfer_amount)
      withdraw(inner_cnn, "Sarah", another_transfer_amount)

      # Rollback exception
      raise DB::Rollback.new
    end
  end
rescue ex
  puts "Exception raised due to: #{ex}"
end

[^ACID]: Theo Haerder and Andreas Reuter. 1983. Principles of transaction-oriented database recovery. ACM Comput. Surv. 15, 4 (December 1983), 287-317. DOI=http://dx.doi.org/10.1145/289.291