摘要
我们在生产中的一个线程遇到了错误,现在在它的余生中,对它所服务的每个查询请求都会产生InvalidRequestError: This session is in 'prepared' state; no further SQL can be emitted within this transaction.
错误!它已经做了天了,现在!这是怎么可能的,我们如何防止它继续下去?
背景
我们在uWSGI上使用Flask应用程序(4个进程,2个线程),Flask SQLAlchemy为我们提供了到SQL Server的数据库连接。
问题似乎始于我们生产中的一个线程在这个Flask SQLAlchemy方法中撕毁了它的请求:
@teardown
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
并且在事务无效时设法调用CCD_ 2。这导致sqlalchemy.exc.InvalidRequestError: Can't reconnect until invalid transaction is rolled back
不顾我们的日志记录配置,将输出输出到stdout,这是有道理的,因为它发生在应用程序上下文拆除期间,而这绝不会引发异常。我不确定如果没有设置response_or_exec
,事务是如何变得无效的,但这实际上是AFAIK的较小问题。
更大的问题是,"已准备状态"错误就是从那时开始的,而且从那以后一直没有停止。每当这个线程提供一个命中DB的请求时,它就会500秒。其他线程似乎都很好:据我所知,即使是在同一进程中的线程也做得很好
胡乱猜测
SQLAlchemy邮件列表中有一个关于"prepared"状态错误的条目,表示如果会话开始提交但尚未完成,而其他人试图使用它,就会发生这种错误。我猜这个线程中的会话从未达到self.session.remove()
步骤,现在也永远不会。
我仍然觉得这并不能解释这个会话是如何在请求之间持续的。我们还没有修改Flask SQLAlchemy对请求范围会话的使用,因此会话应该返回到SQLAlchemey的池中,并在请求结束时回滚,即使是出现错误的会话(尽管不可否认,可能不是第一个,因为这是在应用上下文拆除过程中提出的)。为什么回滚没有发生?如果我们每次都在stdout(在uwsgi的日志中)上看到"无效事务"错误,我可以理解,但我们不是:我只看到过一次,第一次。但每次500秒发生时,我都会看到"准备就绪"状态错误(在我们应用程序的日志中)。
配置详细信息
我们已经关闭了session_options
中的expire_on_commit
,并打开了SQLALCHEMY_COMMIT_ON_TEARDOWN
。我们只是从数据库中读取,还没有写入。我们还在所有查询中使用Dogpile Cache(使用memcached锁,因为我们有多个进程,实际上有2个负载平衡的服务器)。我们的主要查询的缓存每分钟过期一次。
更新2014-04-28:解决步骤
重新启动服务器似乎已经解决了这个问题,这并不完全令人惊讶。也就是说,在我们想好如何阻止它之前,我希望能再次看到它。benselme(下图)建议编写我们自己的teardown回调,并在提交时进行异常处理,但我觉得更大的问题是线程在其余生中都被搞砸了。事实上,在一两次请求后,这个并没有消失,这真的让我很紧张!
编辑2016-06-05:
解决此问题的PR已于2016年5月26日合并。
烧瓶PR 1822
编辑2015-04-13:
谜团解开了!
TL;DR:在2014-12-11编辑中使用拆卸包装配方,确保您的拆卸功能成功
开始了一份同样使用烧瓶的新工作,在我制定拆卸包装配方之前,这个问题再次出现。所以我重新审视了这个问题,终于弄清楚发生了什么。
正如我所想,每当有新的请求出现时,Flask都会将一个新的请求上下文推送到请求上下文堆栈中。这用于支持请求本地全局,如会话。
Flask还有一个与请求上下文分离的"应用程序"上下文的概念。它旨在支持诸如测试和CLI访问之类的功能,而在这些功能中不会发生HTTP。我知道这一点,我也知道这就是Flask SQLA放置DB会话的地方。
在正常操作过程中,请求和应用程序上下文都会在请求开始时推送,并在请求结束时弹出。
然而,事实证明,当推送请求上下文时,请求上下文会检查是否存在现有的应用程序上下文,如果存在,则不会推送新的应用程序
因此,如果应用程序上下文由于引发了一个拆卸函数而没有在请求结束时弹出,它不仅会永远存在,甚至不会在上面推送新的应用程序上下文。
这也解释了我在集成测试中没有理解的一些神奇之处。您可以插入一些测试数据,然后运行一些请求,这些请求将能够访问这些数据,尽管您没有提交。这是可能的,因为请求有一个新的请求上下文,但正在重用测试应用程序上下文,所以它正在重用现有的DB连接。所以这确实是一个功能,而不是一个bug。
也就是说,这意味着你必须绝对确保你的拆卸函数成功,使用下面的拆卸函数包装器。即使没有这个功能,这也是一个好主意,可以避免内存和数据库连接泄漏,但鉴于这些发现,这一点尤为重要。出于这个原因,我将向Flask的文档提交一份PR。(给你)
编辑2014-12-11:
我们最终准备好的一件事是下面的代码(在我们的应用程序工厂中),它封装了每个拆卸函数,以确保它记录异常并且不会进一步引发。这样可以确保应用程序上下文始终成功弹出。显然,在之后,必须执行操作,确保所有拆卸函数都已注册。
# Flask specifies that teardown functions should not raise.
# However, they might not have their own error handling,
# so we wrap them here to log any errors and prevent errors from
# propagating.
def wrap_teardown_func(teardown_func):
@wraps(teardown_func)
def log_teardown_error(*args, **kwargs):
try:
teardown_func(*args, **kwargs)
except Exception as exc:
app.logger.exception(exc)
return log_teardown_error
if app.teardown_request_funcs:
for bp, func_list in app.teardown_request_funcs.items():
for i, func in enumerate(func_list):
app.teardown_request_funcs[bp][i] = wrap_teardown_func(func)
if app.teardown_appcontext_funcs:
for i, func in enumerate(app.teardown_appcontext_funcs):
app.teardown_appcontext_funcs[i] = wrap_teardown_func(func)
编辑2014-09-19:
好吧,事实证明--reload-on-exception
不是一个好主意,如果1.)您使用多个线程,2.)在请求中途终止线程可能会造成麻烦。我原以为uWSGI会像uWSGI的"优雅的重新加载"功能一样,等待该工作者完成所有请求,但事实似乎并非如此。我们开始遇到这样的问题:线程会在Memcached中获取dogpile锁,然后在uWSGI重新加载工作线程时由于另一个线程中的异常而被终止,这意味着锁永远不会被释放。
删除SQLALCHEMY_COMMIT_ON_TEARDOWN
解决了我们的部分问题,尽管我们在session.remove()
期间的应用程序拆卸过程中偶尔会出现错误。这些问题似乎是由SQLAlchemy问题3043引起的,该问题在0.9.5版本中得到了修复,因此希望升级到0.9.5将使我们能够依赖于始终有效的应用程序上下文拆卸。
原件:
这最初是如何发生的仍然是一个悬而未决的问题,但我确实找到了一种防止它的方法:uWSGI的--reload-on-exception
选项。
我们的Flask应用程序的错误处理应该能捕捉到任何东西,所以它可以提供自定义的错误响应,这意味着只有最意外的异常才能一直到达uWSGI。因此,每当发生这种情况时,重新加载整个应用程序是有意义的。
我们还将关闭SQLALCHEMY_COMMIT_ON_TEARDOWN
,尽管我们可能会显式提交,而不是为应用程序拆卸编写自己的回调,因为我们很少向数据库写入。
令人惊讶的是,self.session.commit
周围没有异常处理。提交可能会失败,例如,如果与数据库的连接丢失。因此,提交失败,session
不会被删除,下次特定线程处理请求时,它仍然会尝试使用现在无效的会话。
不幸的是,Flask SQLAlchemy没有提供任何干净的可能性来拥有自己的拆卸功能。一种方法是将SQLALCHEMY_COMMIT_ON_TEARDOWN
设置为False,然后编写自己的teardown函数。
它应该是这样的:
@app.teardown_appcontext
def shutdown_session(response_or_exc):
try:
if response_or_exc is None:
sqla.session.commit()
finally:
sqla.session.remove()
return response_or_exc
现在,你仍然会有失败的提交,你必须单独调查。。。但至少你的线程应该恢复。