問題介紹
爲方便理解,簡單說明一下項目,項目中使用的依賴模塊有: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的對象刷新機制/