跳至內容

交易

當使用資料庫時,通常需要將操作分組,以便在一個操作失敗時,可以回到最近的安全狀態。此解決方案在交易範式中描述,並由大多數資料庫引擎實作,因為它需要滿足 ACID 屬性(原子性、一致性、隔離性、持久性)[^ACID]

考慮到這一點,我們提出以下範例

我們有兩個帳戶(每個帳戶以名稱和金額表示)。

db = get_bank_db

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

在某個時刻,從一個帳戶向另一個帳戶進行轉帳。例如,John 向 Sarah 轉帳 50 美元

我們有兩個帳戶(每個帳戶以名稱和金額表示)。

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

請務必記住,如果其中一個操作失敗,則最終狀態將不一致。因此,我們需要將兩個操作(存款和提款)作為一個操作執行。如果發生錯誤,我們希望回到好像從未執行過該操作的時間點。

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 的行為不同。如果在區塊內引發 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

注意

使用 commitrollback 後,交易將不再可用。連線仍然開啟,但任何語句都將在已終止交易的上下文之外執行。

巢狀交易

顧名思義,巢狀交易是在另一個交易的範圍內建立的交易。以下是一個範例

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

從以上範例中可以觀察到:儘管 outer_tx 正在等待提交,但 inner_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 區塊中的變更(如果我們使用顯式回滾也會發生相同的情況)。

如果回滾是由 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 和 Andreas Reuter. 1983. 面向交易的資料庫恢復原則。ACM Comput. Surv. 15, 4 (1983 年 12 月), 287-317. DOI=http://dx.doi.org/10.1145/289.291