问题介绍
为方便理解,简单说明一下项目,项目中使用的依赖模块有:flask,flask-sqlalchemy,flask-celery等等。
在同步方式调用task函数的时候出现了DetachedInstanceError的异常。出错的代码如下(已简化):
def func():
user = User.query.first()
task_func()
print(user.id)
@celery.task
def task_func():
pass
在访问user的id属性时报错,报错如下:
sqlalchemy.orm.exc.DetachedInstanceError: Instance <User at 0x7fb780d40da0> is not bound to a Session; attribute refresh operation cannot proceed (Background on this error at: http://sqlalche.me/e/bhk3)
查找问题的原因
根据错误提示查找原因,发现原因是:session被关闭导致对象和session失去关联,当对象属性需要加载时则会加载失败。
在代码中并没有调用session.close(),那为什么session会被关闭呢?猜测可能是因为线程切换导致的,因为在flask_sqlalchemy中使用的是scoped_session,那么不同线程的session对象是不同的,所以线程切换有可能导致session关闭。
下面测试同步调用task函数时线程是否进行了切换:
from threading import current_thread
def func():
print(current_thread)
task_func()
print(current_thread)
@celery.task
def task_func():
print(current_thread)
执行结果:
<function current_thread at 0x7f42af472268>
<function current_thread at 0x7f42af472268>
<function current_thread at 0x7f42af472268>
执行结果证明并没有进行线程切换。
那么回到之前的问题,是什么导致了session关闭。再看一下出错代码,正常的数据库查询和函数调用是不太会有问题的,所以只有task装饰器比较可疑。尝试将装饰器移除再执行发现并没有报错,那么原因应该就在这个task装饰器中。
查阅了一些资料以及分析task装饰器的源码后,发现在task装饰器中会创建新的应用上下文对象。代码如下:
class ContextTask(task_base):
"""Celery instance wrapped within the Flask app context."""
def __call__(self, *_args, **_kwargs):
with app.app_context():
return task_base.__call__(self, *_args, **_kwargs)
在出错代码中去除装饰器后模拟创建应用上下文的行为:
def func():
user = User.query.first()
task_func()
print(user.id)
def task_func():
from flask import current_app
with current_app.app_context():
pass
执行后发现会出现同样的异常,则代表异常是这段代码导致的。
继续分析session是在什么地方关闭的,这个Flask对象的应用上下文结束时会执行一些清理操作,代码如下:
class AppContextt(object):
def pop(self, exc=_sentinel):
"""Pops the app context."""
try:
self._refcnt -= 1
if self._refcnt <= 0:
if exc is _sentinel:
exc = sys.exc_info()[1]
self.app.do_teardown_appcontext(exc)
finally:
rv = _app_ctx_stack.pop()
assert rv is self, 'Popped wrong app context. (%r instead of %r)' \
% (rv, self)
appcontext_popped.send(self.app)
def __exit__(self, exc_type, exc_value, tb):
self.pop(exc_value)
if BROKEN_PYPY_CTXMGR_EXIT and exc_type is not None:
reraise(exc_type, exc_value, tb)
这里的do_teardown_appcontext()会调用被teardown_appcontext装饰的函数,代码如下:
class Flask(_PackageBoundObject):
@setupmethod
def teardown_appcontext(self, f):
self.teardown_appcontext_funcs.append(f)
return f
def do_teardown_appcontext(self, exc=_sentinel):
if exc is _sentinel:
exc = sys.exc_info()[1]
for func in reversed(self.teardown_appcontext_funcs):
func(exc)
appcontext_tearing_down.send(self, exc=exc)
而在使用flask_sqlalchemy创建Sqlalchemy对象时会注册一个teardown函数
class SQLAlchemy(object):
def init_app(self, app):
...
@app.teardown_appcontext
def shutdown_session(response_or_exc):
if app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN']:
if response_or_exc is None:
self.session.commit()
self.session.remove()
return response_or_exc
由于项目中配置了SQLALCHEMY_COMMIT_ON_TEARDOWN=True,所以在应用上下文结束时self.session.commit()和self.session.remove()这两行代码都会被执行。
session的remove方法会调用close方法。close方法会调用expunge_all(),并释放所有事务/连接资源。而expunge_all方法将会所有对象从session中移除。
验证session关闭后对象是否从session中移除:
def func():
user = User.query.first()
print(db.session.identity_map.values())
db.session.close()
print(db.session.identity_map.values())
print(user.id)
执行结果证明session关闭后对象确实不在session中了,但是访问对象属性并没有报错,说明仅仅session关闭并不会导致异常。
继续查阅资料发现,commit方法会将所有对象过期,当再次调用对象时会重新去数据库中查询。我们可以通过查看obj._sa_instance_state.expired属性可以查看对象是否过期,打开SQLALCHEMY_ECHO配置可以在执行sql时打印日志,验证代码如下:
def func():
user = User.query.first()
db.session.commit()
print(user._sa_instance_state.expired)
print(user.id)
执行结果为对象的expired值为False,访问对象属性会重新执行查询sql。
最后一起调用session.commit()和session.close()进行测试:
def func():
user = User.query.first()
db.session.commit()
db.session.close()
print(user._sa_instance_state.expired)
print(db.session.identity_map.values())
print(user.id)
执行结果为对象过期并且session中的对象列表为空,访问对象属性时报错。到这里出现异常的原因已经很明显了。
原因总结
同步调用task函数时会创建新的应用上下文,即app.app_context()。在函数调用结束时,应用上下文也会结束,应用上下文结束时会调用sqlalchemy的teardown函数。其中一个由flask_sqlalchemy注册的teardown函数中会调用session.commit()和session.remove(),commit会让对象过期,remove会移除session中的所有对象。这时去访问对象属性则需要会重新从db查询,但是对象已经没有关联的session了,故无法查询导致报错。
解决方法
- sqlalchemy初始化时增加参数expire_on_commit=False,这样在commit之后就不会将对象置为过期。
- 在调用task函数前使用session.expunge_all(),将对象和session的关系断开,这样对象就不会过期了。
- 在调用完task函数时使用db.session.add(obj),将对象再次加入session,这样访问对象属性时就会重新加载了。
推荐使用第一种方法,因为这种方法改起来比较方便,后续代码也不用做特殊处理。
参考文档
http://wiki.mchz.com.cn/pages/viewpage.action?pageId=25069046
https://blog.csdn.net/yangxiaodong88/article/details/82458769
http://blog.0x01.site/2016/10/25/从SQLAlchemy的ObjectDeletedError到SQLAlchemy的对象刷新机制/