06.事务和db_session

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()选项,并进行以下操作之一。

  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会将这些对象保存在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 functioncoroutine

换句话说,不要这样做:

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(),在多线程应用程序中,这需要在每个线程中单独执行。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270