事务和 db_session

数据库事务是逻辑工作单元,可以包含一个或多个查询。事务是原子的,这意味着当事务对数据库进行更改时,要么所有更改在事务提交时成功,要么所有更改在事务回滚时被撤消。

Pony 使用数据库会话提供自动事务管理。

使用 db_session

与数据库交互的代码必须放在数据库会话中。会话设置了与数据库对话的边界。每个与数据库交互的应用程序线程都会建立一个单独的数据库会话,并使用一个单独的 身份映射 实例。此身份映射充当缓存,当您通过主键或唯一键访问对象时,它可以帮助避免数据库查询,因为它已存储在身份映射中。为了使用数据库会话与数据库交互,您可以使用 @db_session() 装饰器或 db_session() 上下文管理器。当会话结束时,它将执行以下操作

  • 如果数据已更改且没有发生异常,则提交事务,否则回滚事务。

  • 将数据库连接返回到连接池。

  • 清除身份映射缓存。

如果您忘记在必要的地方指定 db_session(),Pony 将引发异常 TransactionError: db_session is required when working with the database

使用 @db_session() 装饰器的示例

@db_session
def check_user(username):
    return User.exists(username=username)

使用 db_session() 上下文管理器的示例

def process_request():
    ...
    with db_session:
        u = User.get(username=username)
        ...

注意

当您使用 Python 的交互式 shell 时,您无需担心数据库会话,因为 Pony 会自动维护它。

如果您尝试访问不在 db_session() 范围内的数据库中加载的实例属性,您将获得 DatabaseSessionIsOver 异常。例如

DatabaseSessionIsOver: Cannot load attribute Customer[3].name: the database session is over

发生这种情况是因为此时数据库连接已返回到连接池,事务已关闭,我们无法向数据库发送任何查询。

当 Pony 从数据库读取对象时,它会将这些对象放入身份映射中。稍后,当您更新对象的属性、创建或删除对象时,更改将首先累积在身份映射中。更改将在事务提交时或在调用以下方法之前保存在数据库中:select()get()exists()execute()

db_session 和事务范围

通常,您将在 db_session() 中拥有单个事务。没有显式命令来启动事务。事务从发送到数据库的第一个 SQL 查询开始。在发送第一个查询之前,Pony 会从连接池中获取数据库连接。任何后续的 SQL 查询都将在同一事务的上下文中执行。

注意

SQLite 的 Python 驱动程序不会在 SELECT 语句上启动事务。它只在可以修改数据库的语句上开始事务:INSERT、UPDATE、DELETE。其他驱动程序在任何 SQL 语句上启动事务,包括 SELECT。

事务在使用 commit()rollback() 调用提交或回滚时结束,或者通过离开 db_session() 范围隐式结束。

@db_session
def func():
    # a new transaction is started
    p = Product[123]
    p.price += 10
    # commit() will be done automatically
    # database session cache will be cleared automatically
    # database connection will be returned to the pool

同一 db_session 中的多个事务

如果您需要在同一个数据库会话中拥有多个事务,您可以在会话期间随时调用 commit()rollback(),然后下一个查询将启动一个新事务。身份映射在手动 commit() 后保留数据,但如果您调用 rollback(),缓存将被清除。

@db_session
def func1():
    p1 = Product[123]
    p1.price += 10
    commit()          # the first transaction is committed
    p2 = Product[456] # a new transaction is started
    p2.price -= 10

嵌套 db_session

如果您递归地进入 db_session() 范围,例如通过调用用 db_session() 装饰器装饰的函数,该函数从另一个用 db_session() 装饰的函数调用,Pony 不会创建新的会话,而是会为这两个函数共享同一个会话。数据库会话在离开最外层 db_session() 装饰器或上下文管理器的范围时结束。

如果内部 db_session() 具有不同的设置怎么办?例如,外部一个是默认的 db_session(),而内部一个是定义为 db_session(optimistic=False)

目前 Pony 检查内部 db_session() 选项,并执行以下操作之一

  1. 如果内部 db_session() 使用与外部 db_session() 不兼容的选项 (ddl=Trueserializable=True),Pony 会抛出异常。

  2. 对于 sql_debug 选项,Pony 在内部 db_session() 中使用新的 sql_debug 选项值,并在返回到外部 db_session() 时恢复它。

  3. 其他选项 (strictoptimisticimmediateretry) 对于内部 db_session() 被忽略。

如果在内部 db_session() 中调用 rollback(),它将应用于外部 db_session()

一些数据库支持嵌套事务,但目前 Pony 不支持。

db_session 缓存

Pony 在多个阶段缓存数据以提高性能。它缓存

  • 生成器表达式翻译的结果。如果同一个生成器表达式查询在程序中被多次使用,它只会被翻译成 SQL 一次。这个缓存是针对整个程序的全局缓存,而不是针对单个数据库会话的缓存。

  • 从数据库创建或加载的对象。Pony 将这些对象保存在身份映射中。这个缓存会在退出 db_session() 范围或事务回滚时被清除。

  • 查询结果。如果同一个查询再次使用相同的参数调用,Pony 会从缓存中返回查询结果。这个缓存会在任何实体实例发生改变时被清除。这个缓存会在退出 db_session() 范围或事务回滚时被清除。

在生成器函数或协程中使用 db_session

@db_session() 装饰器也可以用于生成器函数或协程。生成器函数是在其内部包含 yield 关键字的函数。协程是使用 async def 定义或用 @asyncio.coroutine 装饰的函数。

如果在这样的生成器函数或协程内部尝试使用 db_session 上下文管理器,它将无法正常工作,因为在 Python 中上下文管理器无法拦截生成器挂起。相反,你需要用 @db_session 装饰器包装你的生成器函数或协程。

换句话说,不要这样做

def my_generator(x):
    with db_session: # it won't work here!
        obj = MyEntity.get(id=x)
        yield obj

改为这样做

@db_session
def my_generator( x ):
    obj = MyEntity.get(id=x)
    yield obj

对于普通函数,@db_session() 装饰器充当一个作用域。当你的程序退出 db_session() 作用域时,Pony 会通过执行提交(或回滚)来完成事务并清除 db_session 缓存。

在生成器的情况下,程序可以多次重新进入生成器代码。在这种情况下,当你的程序退出生成器代码时,db_session 并没有结束,而是被挂起,Pony 不会清除缓存。同时,我们不知道程序是否会再次回到这个生成器代码。这就是为什么你必须在程序退出生成器时,在 yield 上显式地提交或回滚当前事务。对于普通函数,Pony 会在退出 db_session() 作用域时自动调用 commit()rollback()

本质上,以下是使用 db_session() 与生成器函数时的区别

  1. 你必须在 yield 表达式之前显式地调用 commit()rollback()

  2. Pony 不会清除事务缓存,因此你可以在返回到同一个生成器时继续使用加载的对象。

  3. 对于生成器函数,db_session() 只能用作装饰器,而不能用作上下文管理器。这是因为在 Python 中,上下文管理器无法理解它是在 yield 上退出的。

  4. db_session() 的参数,例如 retryserializable 不能与生成器函数一起使用。在这种情况下,唯一可以使用的参数是 immediate

db_session 的参数

如上所述,db_session() 可以用作装饰器或上下文管理器。它可以接收在 API 参考 中描述的参数。

使用多个数据库

Pony 可以同时使用多个数据库。在下面的示例中,我们使用 PostgreSQL 来存储用户信息,使用 MySQL 来存储地址信息

db1 = Database()

class User(db1.Entity):
    ...

db1.bind('postgres', ...)


db2 = Database()

class Address(db2.Entity):
    ...

db2.bind('mysql', ...)

@db_session
def do_something(user_id, address_id):
    u = User[user_id]
    a = Address[address_id]
    ...

退出 do_something() 函数后,Pony 会对两个数据库执行 commit()rollback()。如果你需要在退出函数之前提交到一个数据库,可以使用 db1.commit()db2.commit() 方法。

用于处理事务的函数

有三个你可以用来处理事务的顶层函数

此外,Database 对象还有三个相应的函数

如果你只使用一个数据库,使用顶层函数或 Database 对象方法之间没有区别。

乐观并发控制

默认情况下,Pony 使用乐观并发控制的概念来提高性能。使用这种概念,Pony 不会获取数据库行上的锁。相反,它会验证没有其他事务修改了它已读取或正在尝试修改的数据。如果检查发现冲突的修改,提交的事务会收到异常 OptimisticCheckError, 'Object XYZ was updated outside of current transaction' 并回滚。

我们应该如何处理这种情况?首先,这种行为对于实现 MVCC 模式(例如 Postgres、Oracle)的数据库来说是正常的。例如,在 Postgres 中,当并发事务更改了相同数据时,你会收到以下错误

ERROR:  could not serialize access due to concurrent update

当前事务会回滚,但可以重新启动。为了自动重新启动事务,你可以使用 db_session() 装饰器的 retry 参数(有关详细信息,请参阅本章后面的内容)。

Pony 如何进行乐观检查?为此,Pony 会跟踪对每个对象属性的访问。如果用户代码读取或修改了对象的属性,Pony 就会在提交时检查该属性值是否仍然与数据库中的值相同。这种方法保证不会出现丢失更新的情况,即在当前事务期间另一个事务更改了同一个对象,然后我们的事务在不知道有更改的情况下覆盖了数据。

在乐观检查期间,Pony 只会验证用户读取或写入的那些属性。同样,当 Pony 更新对象时,它只会更新用户更改的那些属性。这样,两个并发事务可以更改同一个对象的不同属性,并且两者都可以成功。

通常,乐观并发控制会提高性能,因为事务可以在不管理锁或不等待其他事务的锁清除的情况下完成。当冲突很少,并且我们的应用程序更频繁地读取数据而不是写入数据时,这种方法会显示出非常好的结果。

但是,如果写入数据的竞争频繁,反复重新启动事务的成本会影响性能。在这种情况下,悲观锁定可能更合适。

如果你需要关闭属性的乐观并发控制,可以使用 乐观选项易变选项

悲观锁定

有时我们需要锁定数据库中的对象,以防止其他事务修改相同的记录。在数据库中,这种锁定应该使用 SELECT FOR UPDATE 查询来完成。为了使用 Pony 生成这样的锁,你应该调用 Query.for_update() 方法

select(p for p in Product if p.price > 100).for_update()

上面的查询选择所有价格大于 100 的 Product 实例,并锁定数据库中相应的行。锁将在当前事务提交或回滚时释放。

如果你需要锁定单个对象,可以使用实体的 get_for_update 方法

Product.get_for_update(id=123)

当您尝试使用 for_update() 锁定一个对象,而该对象已经被另一个事务锁定,您的请求需要等待直到行级锁被释放。为了防止操作等待其他事务提交,请使用 nowait=True 选项。

select(p for p in Product if p.price > 100).for_update(nowait=True)
# or
Product.get_for_update(id=123, nowait=True)

在这种情况下,如果选定的行无法立即锁定,请求将报告错误,而不是等待。

悲观锁的主要缺点是性能下降,因为数据库锁的开销和并发性的限制。

Pony 如何避免丢失更新

较低的隔离级别提高了多个用户同时访问数据的可能性,但也可能导致数据库异常,例如丢失更新。

让我们考虑一个例子。假设我们有两个账户。我们需要提供一个可以将资金从一个账户转到另一个账户的功能。在转账过程中,我们会检查账户是否有足够的资金。

假设我们使用 Django ORM 来完成这项任务。下面是实现此功能的一种可能方法。

@transaction.atomic
def transfer_money(account_id1, account_id2, amount):
    account1 = Account.objects.get(pk=account_id1)
    account2 = Account.objects.get(pk=account_id2)
    if amount > account1.amount:    # validation
        raise ValueError("Not enough funds")
    account1.amount -= amount
    account1.save()
    account2.amount += amount
    account2.save()

默认情况下,在 Django 中,每个 save() 都在单独的事务中执行。如果在第一个 save() 之后出现错误,金额将消失。即使没有错误,如果另一个事务试图在两个 save() 操作之间获取账户对账单,结果将是错误的。为了避免此类问题,这两个操作应该合并到一个事务中。我们可以通过使用 @transaction.atomic 装饰器来装饰函数来实现这一点。

但即使在这种情况下,我们也可能遇到问题。如果两个银行分支机构同时尝试将全部金额转到不同的账户,这两个操作都将执行。每个函数都将通过验证,最终一个事务将覆盖另一个事务的结果。这种异常称为“丢失更新”。

有三种方法可以防止这种异常

  • 使用 SERIALIZABLE 隔离级别

  • 使用 SELECT FOR UPDATE 代替 SELECT

  • 使用乐观检查

如果您使用 SERIALIZABLE 隔离级别,数据库将不允许提交第二个事务,并在提交期间抛出异常。这种方法的缺点是,这种级别需要更多的系统资源。

如果您使用 SELECT FOR UPDATE,则第一个访问数据库的事务将锁定该行,另一个事务将等待。

乐观检查不需要更多的系统资源,也不锁定数据库行。它通过确保在从数据库读取数据和提交操作之间数据没有更改来消除丢失更新异常。

在 Django 中避免丢失更新异常的唯一方法是使用 SELECT FOR UPDATE,并且您应该显式使用它。如果您忘记这样做,或者您没有意识到您的业务逻辑存在丢失更新问题,您的数据可能会丢失。

Pony 允许使用所有三种方法,默认情况下启用第三种方法,乐观检查。这样,Pony 就可以完全避免丢失更新异常。此外,使用乐观检查可以实现最高的并发性,因为它不会锁定数据库,也不需要额外的资源。

在 Pony 中,类似的转账功能看起来像这样

SERIALIZABLE 方法

@db_session(serializable=True)
def transfer_money(account_id1, account_id2, amount):
    account1 = Account[account_id1]
    account2 = Account[account_id2]
    if amount > account1.amount:
        raise ValueError("Not enough funds")
    account1.amount -= amount
    account2.amount += amount

SELECT FOR UPDATE 方法

@db_session
def transfer_money(account_id1, account_id2, amount):
    account1 = Account.get_for_update(id=account_id1)
    account2 = Account.get_for_update(id=account_id2)
    if amount > account1.amount:
        raise ValueError("Not enough funds")
    account1.amount -= amount
    account2.amount += amount

乐观检查方法

@db_session
def transfer_money(account_id1, account_id2, amount):
    account1 = Account[account_id1]
    account2 = Account[account_id2]
    if amount > account1.amount:
        raise ValueError("Not enough funds")
    account1.amount -= amount
    account2.amount += amount

最后一种方法是 Pony 默认使用的,您不需要显式添加任何其他内容。

事务隔离级别和数据库特性

有关此主题的更多详细信息,请参阅 API 参考

处理中断

db.bind(...) 上,Pony 打开与数据库的连接,然后将其存储在线程本地连接池中。

当应用程序代码进入 db_session 并执行查询时,Pony 从池中获取已打开的连接并使用它。退出 db_session 后,连接将返回到池中。如果您启用日志记录,您将看到 Pony 的 RELEASE CONNECTION 消息。这意味着连接没有关闭,而是返回到连接池。

有时连接会被数据库服务器关闭,例如当数据库服务器重新启动时。之后,先前打开的连接将变为无效。如果发生这种断开连接,很可能是在 db_session 之间发生的,但有时也可能在活动 db_session 期间发生。Pony 已准备好应对这种情况,并且可以以智能的方式重新连接到数据库。

如果 Pony 执行查询并收到连接已关闭的错误,它将检查 db_session 的状态,以了解在当前 db_session 期间是否已将任何更新发送到数据库。如果 db_session 刚刚开始,或者所有查询都是 SELECT,Pony 假设在幕后重新打开连接并继续相同的 db_session 是安全的,就好像没有发生任何异常情况一样。但是,如果在先前连接变为无效之前,在活动 db_session 期间已将一些更新发送到数据库,则意味着这些更新已丢失,并且无法继续此 db_session。然后 Pony 将抛出异常。

但在大多数情况下,Pony 能够静默地重新连接,因此应用程序代码不会注意到任何问题。

如果您想关闭存储在连接池中的连接,您可以执行 db.disconnect() 调用,请参阅 disconnect()。在多线程应用程序中,这需要在每个线程中单独完成。