06.事务和db_session
数据库事务是一个逻辑工作单位,它可以由一个或多个查询组成。事务是原子式的,这意味着当事务对数据库进行更改时,要么事务提交时所有的更改都成功,要么事务回滚时所有的更改都被撤销。
Pony使用db_session
提供了自动事务管理。
使用db_session工作
与数据库交互的代码必须放在数据库会话中,会话设置了与数据库对话的边界。
每个与数据库交互的应用程序线程都建立了一个单独的数据库会话,并使用一个单独的Identity Map实例。这个Identity Map
(身份图/恒等映射??)作为缓存,当你通过主键或唯一键访问一个对象,而这个对象已经存储在身份图中时,这个Identity Map
可以帮助避免数据库查询。
为了使用数据库会话与数据库工作,你可以使用@db_session()
装饰器或db_session()
上下文管理器。当会话结束时,它会执行以下操作:
- 如果数据被改变,并且没有发生异常,则提交事务,否则回滚事务。
- 返回数据库连接到连接池。
- 清除
Identity Map
缓存。
如果你忘记在必要的地方指定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从数据库中读取对象时,它会将这些对象放到Identity Map
中。之后,当你更新一个对象的属性(update),创建(create)或删除(delete)一个对象时,这些变化会先累积到Identity Map
中。
这些更改将在事务提交时或在调用以下方法之前保存在数据库中: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():
#一个新的事务被启动
p = Product[123]
p.price +=10
# commit()将自动完成
# 数据库会话缓存将被自动清除
#数据库连接将被返回到池中
同一个db_session内的多个事务
如果在同一个数据库会话中需要有一个以上的事务,你可以在会话期间的任何时候调用commit()或rollback(),然后下一次查询将启动一个新的事务。在手动提交()之后,Identity Map
会保留数据,但如果你调用rollback(),缓存会被清除。
@db_session
def func1():
p1 = Product[123]
p1.price +=10
commit() # 第一个事务被提交
p2 = Product[456] # 另一个新的事务开始了
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()选项,并进行以下操作之一。
- 如果内部的db_session()使用了与外部的db_session()不兼容的选项(
ddl=True
或serializable=True
),Pony会抛出一个异常。 - 对于
sql_debug
选项,Pony 在内部的db_session()
中使用新的 sql_debug 选项值,并在返回到外部的db_session()
时恢复它。 - 其他选项(
strict
、optimistic
、immediate
和retry
)对于内部db_session()
来说是被忽略的。
如果在内部的db_session()
内部调用了rollback()
,它将被应用到外部db_session()
。
有些数据库支持嵌套事务,但目前Pony不支持。
db_session缓存
Pony在几个阶段缓存数据,以提高性能。它缓存了。
- 生成器表达式转换的结果
如果同一个生成器表达式查询在程序中被多次使用,那么它将只被翻译成SQL一次。这个缓存对整个程序是全局的,而不仅仅是单个数据库会话的缓存。
- 从数据库中创建或加载的对象。
Pony会将这些对象保存在Identity Map
中。这个缓存在离开db_session()
作用域或事务回滚时被清除。
- 查询结果。
如果使用相同的参数再次调用相同的查询,Pony会从缓存中返回查询结果。
一旦任何一个实体实例被改变,这个缓存就会被清除。
这个缓存在离开db_session()
作用域或事务回滚时被清除。
将db_session
与生成器函数或coroutines一起使用
@db_session()
装饰器也可以和生成器函数或coroutines一起使用。
生成器函数是指在其内部包含 yield 关键字的函数。
coroutine 是使用 async def 定义的函数,或者用@asyncio.coroutine
装饰的函数。
如果在这样的生成器函数或coroutine里面,你尝试使用db_session
上下文管理器,它将无法正常工作。
因为在Python中,上下文管理器不能拦截生成器的暂停,相反,你需要用@db_session
装饰符来包装你的generator function
或 coroutine
。
换句话说,不要这样做:
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也不会清除缓存。同时,我们也不知道程序是否会再次回到这个生成器代码中来。
这也是为什么在程序离开生成器时,必须显式提交或回滚当前事务的原因。在普通函数上,Pony会在离开db_session()
作用域时自动调用commit()
或rollback()
。
本质上,这里是使用db_session()与生成器函数时的区别:
- 你必须在
yield
表达式之前明确地调用commit()
或rollback()
。 - Pony不会清除事务缓存,所以当你回到同一个生成器时,可以继续使用加载的对象。
- 在使用生成器函数时,
db_session()
只能作为一个装饰器,而不是上下文管理器。这是因为在Python中,上下文管理器无法理解它被留到了yield
上。 -
db_session()
的参数,如retry、serializable
等参数不能与生成器函数一起使用。在这种情况下,唯一可以使用的参数是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()
方法。
用于事务处理的函数
有三个高级别函数可以用来处理事务。
- commit()
- rollback()
- flush()
同时,数据库对象也有三个对应的函数。
- Database.commit()
- Database.rollback()
- Database.flush()
如果你在一个数据库中工作,使用上层方法和数据库对象方法是没有区别的。
优化的并发控制
默认情况下,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更新一个对象时,它只更新那些被用户更改的属性。这样一来,就有可能有两个并发事务改变同一个对象的不同属性,并且都能成功。
一般来说,乐观的并发控制可以提高性能,因为事务可以在不需要管理锁的情况下完成,也不需要事务等待其他事务的锁清除。这种方法在冲突很少的情况下显示出了非常好的效果,而且我们的应用程序读数据的次数多于写数据的次数。
但是,如果写数据的冲突频繁,反复重启事务的成本会伤害性能。在这种情况下,悲观锁定可能更合适。
如果你需要关闭某个属性的乐观并发控制,可以使用乐观选项(optimistic option)或波动选项( volatile option.)。
悲观锁定
有时我们需要在数据库中锁定一个对象,以防止其他事务修改同一记录。
在数据库中,这样的锁定应该使用SELECT FOR UPDATE
查询来完成。为了使用Pony生成这样一个锁,你应该调用Query.for_update()
方法:
select(p for p in Product if p.price > 100).for_update()
上面的查询选择所有价格大于100的产品实例,并在数据库中锁定相应的记录。当提交或回滚当前事务时,该锁将被释放。
如果你需要锁定单个对象,可以使用实体的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_sessions
之间,但有时也可能是在活动的db_session
期间发生。
Pony会对这种情况做好准备,并可能以智能方式重新连接到数据库。
如果Pony在执行一个查询时,收到一个错误的连接被关闭的消息,Pony会检查db_session
的状态,以知道在当前db_session
期间是否有任何更新被发送到数据库。如果db_session
刚刚开始,或者所有的查询都只是SELECT,Pony会假定重新打开连接是安全的,并继续执行同样的db_session
,就像没有发生任何异常一样。
但是在db_session
会话激活期间,如果一些更新已经被发送到了数据库,这意味着这些更新已经丢失了,不可能再继续这个db_session
,这时Pony会抛出一个异常。
但是在大多数情况下,Pony能够默默地重新连接,所以应用程序代码不会注意到任何东西。
如果你想关闭存储在连接池中的连接,你可以执行db.disconnect()
调用,参见disconnect()
,在多线程应用程序中,这需要在每个线程中单独执行。