在flask中同步调用celery.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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值