SQLAlchemy 中的 QueuePool 出现 TimeoutError

表现

咱在使用 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)