SQLAlchemy Session机制研究

8,692 阅读13分钟

1. 背景

SQLAlchemy是Python中经常使用的一种对象关系映射模型(Object Relational Mapping,ORM),其作用是将数据库关系映射为Python中的对象,代码中不用直接写SQL语句,而是通过对象来操作数据库,可以使数据库操作变得更加简单清晰,同时能有效避免SQL注入等安全问题。

最近在做web后台相关项目时,使用到了SQLAlchemy来操作MySQL数据库,之间由于刚接触ORM,对SQLAlchemy操作数据库的Session机制理解不深,导致在做项目的过程中出现了一些“奇怪”的BUG,总结如下:

  • BUG1: 一个请求(线程)里面对数据库记录的修改尚未提交时,被另一个请求(线程)里的rollback操作回滚掉,导致数据更新失败
  • BUG2: 日志中时常莫名其妙的打印一些“sqlalchemy.engine.base.Engine ROLLBACK”的日志信息
  • BUG3: 对同一个Session查询的结果集进行遍历,如果遍历过程中涉及到该Session的更新修改操作,可能会导致之前查询结果数据集丢失

为了弄清楚导致以上BUG的原因,我查阅了一些SQLAlchemy官方文档和Session机制相关资料,并简单剖析了SQLAlchemy Session相关源码,总算对产生问题的原因有了线索。本文将尝试对SQLAlchemy Session管理机制进行剖析,找出平常使用SQLAlchemy过程中的一些注意事项,并针对上述问题分析其产生原因,希望可以帮助有需要的同学在今后对SQLAlchemy ORM的使用过程中减少类似错误。

2.SQLAlchemy Session

在整个SQLAlchemy ORM的使用过程中,最重要的部分便是ORM与底层数据库之间的会话(Session),Session负责将Python的SQL操作通过底层连接(Connection)发送到数据库,并为会话期间加载或与之关联的对象提供“保留区域”。

2.1 Session创建流程

SQLAlchemy Session的一般创建流程如下(以操作MySQL为例):

  • ① 首先,通过create_engine创建一个数据库引擎实例,创建引擎时除了可以指定要连接的数据库,还可以指定与数据库之间的数据交换的编码格式、数据库连接池类型及配置等信息。

  • ② 创建完引擎后,SQLAlchemy会默认为我们生成一个连接池(Connection Pool),但是一般都不推介直接使用连接来进行数据库操作,而是通过建立在某一个连接之上的会话(Session)来进行数据库的增删查改。而创建会话时,通常先创建一个Session工厂(sessionmaker本质上实现了一个工厂模式),再通过这个工厂方法来创建一个Session实例。

2.2 管理Session生命周期,避免使用全局Session

何时创建session?何时关闭session?

session一般通过session_factory获取,通常一个应用中声明一个工厂就够了,因此在程序中可以把session_factory声明为一个全局对象,然后在需要的时候通过该工厂创建一个Session实例。比如将session_factory放在包的__init__.py里面,这样其它模块就可以通过“from mypackage import session_factory”引入该工厂方法,进而创建Session实例。然而应注意,SQLAlchemy Session并不是线程安全的,因此被声明为全局作用域的应该是session_factory,而不是Session。对于Session,其作用域尽可能声明局部的(比如限定于某个线程、某个web请求),否则在多线程等并发场景下很容易引起无法预料的错误。比如以下代码就会导致BUG1。 这里首先引入了一个全局session和一张User表(内容如下): update_thread尝试将Jack的年龄更新为25,但是更新操作还未提交的时候,rollback_thread则直接回滚了,由于两个线程共用了同一个全局session,因此update_thread的更新操作会被rollback_thread回滚掉,导致更新失败。从控制台日志也可以看出,事务并未提交,而是被回滚了。

修改代码如下,便可发现更新操作可以成功执行而不受回滚线程影响。

通常,session的生命周期和事务的生命周期保持一致,访问操作数据库的方法对象隔离和独立,每个线程或请求都分配一个独立的session。在并发场景下,尤其是在自己开发一些多线程程序时,要做好session生命周期管理,尽可能不要使用全局session,不同线程间session应保持隔离,避免出现“竞争”导致事务的不一致。

2.3 并发场景下推介使用线程安全的scoped_session

尽管在并发场景下我们推荐在每个线程内通过session_factory获取新的session实例来进行数据库操作以避免导致事务不一致等现象,但是在并发量比较大的情况下,频繁创建、销毁session会导致一定的性能开销。同时,如果在同一线程内多次获取session,也完全没有必要每次都创建一个新的session实例,而应该是共用同一个session。

SQLAlchemy提供了scoped_session来对sessionmaker进行封装,通过“线程本地化存储技术”来提供线程安全的全局session。通过scoped_session,可以保证同一个线程中多次通过session_factory获取session实例时返回的是同一个session实例。同时,在不同的线程中还能获取到该全局session的不同“副本”,以此保证线程间session的隔离性。

如下图所示,直接通过sessionmaker返回的session_factory获取两个session实例,那么这两个实例是不同的;但是如果用scoped_session封装一下sessionmaker,可以发现多次调用session_factory获取到的session实例都是同一个实例。


直接通过session_factory获取session实例


通过scoped_session获取session实例

如何保证一个线程内获取的session实例相同?

scoped_session本质上是实现了一个“线程级”的单例模式,它包含了一个Thread Local的注册表,通过scoped_session获取session实例时,会从注册表中检查当前线程是否已经创建过session实例了,如果有,则直接返回该session;如果没有,则会通过sessionmaker返回的工厂创建一个新的session,并将该session保存到注册表中。

如何保证不同线程间session隔离性?

scoped_session生成session实例时,会把session保存到一个注册表中,而这个注册表是一个通过threading.local()生成的Thread Local对象,虽然这个对象是全局的,但这个全局变量只有在当前线程才能访问,如果在其他线程中访问这个Thread Local对象,则会在该线程中开辟一块单独的空间来存储一份Thread Local对象的副本,即所谓的“线程本地化存储”。通过这种方式便使得Thread Local对象中保存的数据(也就是session)在线程间相互隔离,不会出现不同线程竞争session的情况。

因此,在并发场景下推介使用scoped_session。以一个web应用为例,scoped_session使用流程如下:

2.4 Session中事务管理

SQLAlchemy默认所有数据库操作都是基于事务的。在SQLAlchemy中事务的提交方式分为两种,通过指定sessionmaker的autocommit参数来区分:

  • autocommit为False(默认为False)。在这种模式下我们不需要显示的开启一个事务,但是需要通过commit()手动提交事务,每次提交事务以后会刷新当前事务中数据库操作到数据库事务缓冲区,而后自动开启一个新的事务。
  • autocommit为True。这种模式下,所有的数据库操作都不需要我们显示提交,SQLAlchemy会把每一个数据库操作作为一个单独的事务并自动提交到数据库。这种模式存在一个很致命的问题:所有的数据库操作都无法通过rollback()回滚,如以下代码:

首先我们将Jack的年龄更新为-1,显然这是个错误的操作(年龄不可能为负),因此我们尝试回滚撤销刚才的更新操作。此时,若查看数据库会发现Jack的年龄为-1,也就是说代码中的回滚操作没有生效。为什么呢?前面已经说过autocommit=True的模式下,所有的数据库操作都是一个单独的事务并且自动提交。因此上述代码中的更新操作是一个事务1,事务1自动提交完成后自动开启了事务2,而session.rollback()所处的环境是事务2,当然就不能回滚事务1的操作。

因此,日常业务中这种模式用得比较少,而且通常的业务逻辑中都不会把一个简单的数据库操作(比如一次查询或一次更新)作为一个单独的事务提交,而是会把一系列数据库操作作为一个事务。那么,为了避免在这种模式下每个操作都以独立事务形式提交,可以通过session.begin()显示的开启一个事务,最后再通过session.commit()提交或者session.rollback()回滚。

除了autocommit,sessionmaker还有个比较重要的参数——autoflush,其值默认为True,指SQLAlchemy会自动调用session.flush()把当前事务中数据库操作从python缓冲区刷新到数据库事务缓冲区,这里flush和commit容易混淆,可以理解它们之间的区别如下:

  • flush:预提交,等于提交到数据库内存,但还未写入数据库文件,flush之后仍可以通过session.rollback()回滚之前的操作。
  • commit:就是把内存里面的东西直接写入数据库文件,可以提供查询了,此时即便调用session.rollback()也无法再撤销之前的操作了。

通常情况下,SQLAlchemy官方建议在sessionmaker中使用autoflush=True, autocommit=False组合,系统会自动帮我们把更新刷新到数据库内存,但最终需要我们手动提交写入数据库文件。注意,不管是进行查询还是更新、删除等操作,最好都显示的提交一下当前事务。如果没有显示调用session.commit()提交事务,程序便直接退出或者调用了session.close(),那么SQLAlchemy默认会回滚之前的操作。这也是导致BUG2的原因,我们程序中有些数据库的查询操作没有显示提交,这样做本质上没有太大问题,因为查询操作不会改变数据库状态,所以提不提交影响不大。只是SQLALchemy是基于事务的,不提交的话SQLALchemy会自动的回滚当前查询操作所在的事务,因此后台日志中会出现一个ROLLBACK相关的信息。

2.5 Session是否是缓存

严格意义上,Session并不是缓存,但又的确具有一定的“缓存”的功能。Session中实现了一种叫做Identity Map的映射表,并且会把当前Session上下文中从数据库查询结果集的每一行记录都映射为一个Python对象保存到Identity Map中,Map的key就是行的主键。尽管如此,Identity Map并不会用作查询缓存,也就是说任何时候通过session.query()进行查询时,即便要查询的记录对应的实例对象已经存在于Identity Map中了,也不会直接从Identity Map中获取,而是生成SQL重新发起数据库查询,最后又将查询的结果集存入Identity Map,如果此次查询结果中某些对象已经在Identity Map中存在,那么会直接覆盖之前的数据。这就解释了BUG3产生的原因:在一个事务中先进行了一次查询操作(查询的结果会保存到Identity Map中),而后又进行更新等操作,如果更新的对象涉及到之前查询的结果集,那么即便我们没有显示的去修改之前查询的结果集,其数据也会发生变化,如下图代码所示。

对应输出如下:

分析:

  1. 首先我们将jack的信息查询并打印出来,如红框1所示,此时jack原来的年龄为20。
  2. 然后我们又通过session去查询名为“Jack”的信息,并更新其年龄为26(注意这里并没有通过jack对象或者user_list对象去更新),此时事务尚未提交(即尚未写入数据库文件),再次打印jack对象(如红框2),发现其年龄变为了26。我们明明没有修改过jack对象甚至user_list对象的内容,为什么对象的数据会发生变化?这里至少可以说明两个问题:
    • 第一,数据发生变化的原因其实前面已经说过了,虽然更新操作并没有直接对第一次查询的结果进行修改,但是其涉及到的行和第一次查询涉及到的行是同一行,那么在Identity Map中对应的实际上就是同一个对象,因此回存到Identity Map时就会覆盖之前的值。从内存结构上来理解,jack和user_list都只是对象的引用,而对象本身是存储在堆上的,堆上的值被覆盖改变了,引用的数据当然也会发生变化。
    • 第二,为什么Identity Map中的数据发生变化时,能提现到jack(或者user_list)对象上,这说明当访问查询结果集对象时,是直接通过Identity Map获取数据的,前提是当前事务未提交,注意这个前提,因为下面很快就可以看到事务提交后会发生什么变化。
  3. 提交事务,再次打印jack(如红框3),jack的内容和第二次打印一致,这一点很容易理解,和第二次的区别就是把更新写入了数据库。但是仔细观察红框3的输出可以发现,打印jack时,相比红框2还多出了一些SQLALchemy框架的日志,根据日志的内容可以发现是发起了一次数据库查询操作,重新从数据库中去查询了jack的信息,这一点从最后一行“ROLLBACK”的日志也可以看出在commit之后又产生了一个新的事务,而这个事务就是这次查询操作开启的。同样一行代码,为什么红框2里面是直接从Identity Map读取jack的信息,而红框3中又要重新发起数据库查询才能获取?关键在于session.commit(),这一行代码除了把事务的变更提交到数据库,还有个重要作用——让当前Identity Map中“缓存”对象过期,一旦过期就会被清除,此时再访问这些对象时就只能从数据库重新获取。从Identity Map中查询的流程可如下图所示:

3. 总结

SQLAlchemy是Python中经常使用的ORM框架,可以帮助我们非常方便完成各种数据库操作,避免直接写各种复杂的SQL语句,提高我们的开发效率。总体而言SQLAlchemy的使用很简单,但是如果对其某些实现细节、原理不清楚的话,使用过程中可能会导致一些令人摸不着头脑的问题。因此,本文总结了自己在项目过程中使用SQLAlchemy遇到的一些问题及分析结果,希望能帮助有需要的同学避免类似问题。由于也是刚接触SQLAlchemy,理解程度有限,如果有写理解有误还请大家指正。

4. 参考文献