在flask中同步調用task函數的問題

問題介紹

爲方便理解,簡單說明一下項目,項目中使用的依賴模塊有: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了,故無法查詢導致報錯。

解決方法

  1. sqlalchemy初始化時增加參數expire_on_commit=False,這樣在commit之後就不會將對象置爲過期。
  2. 在調用task函數前使用session.expunge_all(),將對象和session的關係斷開,這樣對象就不會過期了。
  3. 在調用完task函數時使用db.session.add(obj),將對象再次加入session,這樣訪問對象屬性時就會重新加載了。

推薦使用第一種方法,因爲這種方法改起來比較方便,後續代碼也不用做特殊處理。

參考文檔

http://wiki.mchz.com.cn/pages/viewpage.action?pageId=25069046

https://stackoverflow.com/questions/30347090/pushing-celery-task-from-flask-view-detach-sqlalchemy-instances-detachedinstanc/30348496#30348496

https://blog.csdn.net/yangxiaodong88/article/details/82458769

http://blog.0x01.site/2016/10/25/從SQLAlchemy的ObjectDeletedError到SQLAlchemy的對象刷新機制/

https://docs.sqlalchemy.org/en/13/orm/session_api.html?highlight=commit#sqlalchemy.orm.session.Session.commit

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章