表现
咱在使用 Flask-SQLAlchemy 或者 SQLAlchemy 中有时候会出现类似这样的错:
TimeoutError: QueuePool limit of size 10 overflow 10 reached, connection timed out, timeout 30
出现这个错的根本原因是因为该进程对数据库的连接池满了,且等待之前的 session 超时。
出现这个错的时候一般后端的表现为有部分请求非常慢,或者大部分请求都非常慢,如果后端频繁出现这个问题的时候,可以根据这两种情况进行分析:
-
所有请求都变得非常慢
- 出现这种现象的时候,大部分情况下都会伴随大量慢 SQL 记录,可以敲一下 MySQL 的慢查询记录,分析下慢查询,然后该加索引的加索引,该加缓存的加缓存。
-
部分请求非常慢,或者直接报错
- 这种现象比较特殊,目前为止咱遇到过的情况类似这样:在后端需要访问一个外部的服务,比如 GitHub 等等,但是这个这个请求因为各种原因要等好久才能返回,但是 SQLAlchemy 已经给你开了一个 session,所以这个 session 一直被当前线程 hold 住,然后就超时报错了。
调试
那咱就想调试第二种情况,如何重现呢?
可以写一个非常简单的程序:
from gevent import monkey
monkey.patch_all()
import time
from gevent import wsgi
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+mysqlconnector://root:[email protected]:3306/app'
app.config['SQLALCHEMY_POOL_SIZE'] = 1
app.config['SQLALCHEMY_POOL_TIMEOUT'] = 1
app.config['SQLALCHEMY_MAX_OVERFLOW'] = 0
db = SQLAlchemy(app)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True)
email = db.Column(db.String(120), unique=True)
@classmethod
def create_test(cls):
u = cls("123456", "123456")
db.session.add(u)
try:
db.session.commit()
except Exception as e:
db.session.rollback()
u = User.query.filter(User.username == "123456", User.email == "123456").first()
finally:
return u
def __init__(self, username, email):
self.username = username
self.email = email
def __repr__(self):
return '<User %r>' % self.username
@app.route('/create')
def create():
u = User.create_test()
return "User: {}.{}.{}".format(u.id, u.username, u.email)
@app.route('/')
def index():
u = User.query.filter(User.username == "123456").first()
time.sleep(5)
return "hello, world: " + u.username
def main():
wsgi.WSGIServer(('127.0.0.1', 5000), application=app).serve_forever()
if __name__ == '__main__':
import sys
sys.exit(int(main() or 0))
这里咱用 Flask-SQLAlchemy 的官方教程做了非常简单的反面教材_(┐「ε:)_
。
最顶上的几个配置的意思是:
app.config['SQLALCHEMY_POOL_SIZE'] = 1
app.config['SQLALCHEMY_POOL_TIMEOUT'] = 1
app.config['SQLALCHEMY_MAX_OVERFLOW'] = 0
- Session Pool 的大小
- Session Pool 的 TimeOut 时间
- Session Pool 的 Overflow 的大小
Overflow 的大小意思是当 Session Pool 满了之后,还可以从这里拿多少个临时的 Session(用完就关的那种?)。
启动起来后,测试一下非常简单,敲两个 curl 就行:
$ curl http://127.0.0.1:5000/ & curl http://127.0.0.1:5000/
[1] 74108
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
User: 1.123456.123456[1]+ Done curl http://127.0.0.1:5000/
马上就有 500 丢出来惹!
...
File "/usr/local/lib/python2.7/site-packages/sqlalchemy/pool.py", line 376, in connect
return _ConnectionFairy._checkout(self)
File "/usr/local/lib/python2.7/site-packages/sqlalchemy/pool.py", line 713, in _checkout
fairy = _ConnectionRecord.checkout(pool)
File "/usr/local/lib/python2.7/site-packages/sqlalchemy/pool.py", line 480, in checkout
rec = pool._do_get()
File "/usr/local/lib/python2.7/site-packages/sqlalchemy/pool.py", line 1053, in _do_get
(self.size(), self.overflow(), self._timeout))
TimeoutError: QueuePool limit of size 1 overflow 0 reached, connection timed out, timeout 1
因为我们在 index
函数中,有一个 time.sleep(5)
,这个 sleep 可以模拟一些外部的慢调用。
那么如果我们真的要有一些外部请求确实这么慢的要死怎么办呢?这里我们可以把当前 session remove 掉,等请求完成后再拿一个新的来用。
@app.route('/')
def index():
u = User.query.filter(User.username == "123456").first()
db.session.remove()
time.sleep(5)
return "User: {}.{}.{}".format(u.id, u.username, u.email)
我们把 index
函数改成这样,在执行耗时的外部请求前,把它 remove 掉,等请求结束后再把它拿回来。但是我们好像就看见了丢 session 没看见你拿 session 啊?是这样的,db.session
是一个 LazyLoad 的对象,他其实是一个工厂函数,你可以通过 db.session()
拿到一个真正的 session。而且 Flask-SQLAlchemy 默认给你的 session 是一个 scoped_session
,是一个 ThreadLocal 的对象,在大部分情况下,一个线程里拿到的都是同一个 session,你可以多次调用 db.session()
观察其 id,当你 db.session.remove()
了一次后,db.session()
才会拿到不一样的 session。
Pythonic(ZhuangBi)
如何优雅地处理这种情况呢?( _ ͡° ͜ʖ ͡° )_
这里比较适合用 with 来处理,咱写一个简单的示例:
import contextlib
@contextlib.contextmanager
def session_removed():
db.session.remove()
yield
然后就可以简单地来管理你的 session 了撸:
with session_removed():
time.sleep(5)