Flask-SQLAlchemy内存泄露问题
现象及问题
Flask应用异步子线程跑批时,每次循环到400次左右时就会有1个worker
(gunicorn
启动4个worker
)不明原因地发生重启,supervisor
和应用无异常日志,CPU、内存占用在运行过程中持续升高,分别涨到110%和90%时worker重启,才回落。如果批次数据不大,跑完之后虽然worker不会重启,但是内存却一直没有释放,占用70%(正常20%)。
初步怀疑是内存泄露,检查循环的代码块,重点语句依次注释后压测发现问题是由于循环中的数据库更新操作导致的。问题代码块中每个循环内会执行一次数据库单条记录的update操作,并commit()。
示例代码:
for i in range(1000):
try:
rt = Test.query.filter(Test.a == str(i)).update({Test.b: str(i + 1)})
db.session.commit()
except Exception as e:
db.session.rollback()
解决方案
解决SQLAlchemy
内存释放问题,可以使用手工释放的方式,执行db.session.close()
方法(Flask-SQLAlchemy
执行close
不会断开连接,只是把连接放回连接池)。
for i in range(1000):
try:
rt = Test.query.filter(Test.a == str(i)).update({Test.b: str(i + 1)})
db.session.commit()
db.session.close()
except Exception as e:
db.session.rollback()
重新跑批时,cpu占用从有问题的110%
降到平稳的个位数,内存占用从有问题的90%
降到平稳的20%
,再也不担心worker
挂掉了。
原理解析
web应用在每次接受到请求时会通过中心化的工厂创建当前线程的scoped_session
(线程安全的session),请求过程中使用该session完成与数据库之间的交互,请求结束时自动销毁session(释放内存)。注意,session可以包含多个事务。
对session的生命周期有了一定了解之后,回到问题,commit和close对内存处理的差别在哪?在commit()
的源码中,也可以看到调用了session.close()
为什么还是会导致内存占用。为了对比二者的区别,接下来从官网和源码逐个分析比较。
Session.commit()
Flush pending changes and commit the current transaction.
By default, the
Session
also expires all database loaded state on all ORM-managed attributes after transaction commit. This so that subsequent operations load the most recent data from the database. This behavior can be disabled using theexpire_on_commit=False
option tosessionmaker
or theSession
constructor.
源码:
总结:commit会将数据库的操作同步到数据库中执行,并且在expire_on_commit
为True时,使当前session所有ORM管理的变量过期。问题就是这个过期,并不会实际全部清理掉session中的内存。
Session.close()
Close out the transactional resources and ORM objects used by this
Session
.This expunges all ORM objects associated with this
Session
, ends any transaction in progress and releases anyConnection
objects which thisSession
itself has checked out from associatedEngine
objects. The operation then leaves theSession
in a state which it may be used again.
源码:
总结:session的close方法主要是调用expunge_all()
方法并且清理事务,expunge_all()
方法会清理全部该session使用过的ORM对象,实际地释放session的内存。close()
方法不会阻止该session再次被使用。
总结
总而言之,方法很简单,但是很好用。session的生命周期与web请求的生命周期基本相同。对于长生命周期的session对象,在循环中或者大批量的查询之后提交session事务并不能实际释放内存,commit只是将ORM对象过期,要想实际地释放内存,需要显式调用session.close()
方法或者expunge_all()
方法。
我们日常开发中容易误以为session提交之后,会理所当然地以为SQLAlchemy帮我们释放这部分的内存,可实际上它只是让它失效,并没有实际地释放,在循环中导致内存和CPU上涨,执行效率变慢。