跳至内容

事务

在处理数据库时,通常需要将操作分组,以便如果一个操作失败,我们可以回滚到最新的安全状态。此解决方案在事务范式中有所描述,并且由大多数数据库引擎实现,因为它对于满足 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

在外层事务块中触发的回滚,回滚了所有更改,包括内层事务块中的更改(如果我们使用显式回滚,也是如此)。

如果回滚是由内层事务块中的异常触发的,则会回滚所有更改,包括外层事务中的更改。

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_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

如果在内层事务块中引发了 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