OpenResty 中不当使用 MySQL 连接池导致事务被回滚,openrestymysql


目录

    • 目录
    • 场景
    • 错误的封装 query_db
      • dbconnect and connection pool
      • dbset_keepalive and connection pool
      • 连接何时关闭
      • 原因 - 误用连接池
    • 疑问
      • 为什么 http_request 用同步阻塞方式不会有问题
      • 既然 query_db 也是异步非阻塞的为什么多次调用 query_db 连接不会被其它 routine 抢占

场景

为了使用方便,server 封装了一个执行 SQL 语句的函数:

function query_db(sql)
    db, err = mysql:new()
    ok, err = db:connect(host, port, ...)
    res, err, errcode, sqlstate = db:query(sql)
    ok, err = db:set_keepalive(max_idle_timeout, pool_size)
    return res
end

ModuleA 和 ModuleB 共用改函数。二者的代码大致如下:

function http_request(url)
    -- ...
end

function ModuleAInterface()
    res = query("BEGIN")
    res = query(update_sql1)
    res = query(update_sql2)
    ok, err = http_request("ModuleBInterface")
    if ok then
        res = query("COMMIT")
    else
        res = query("ROLLBACK")
    end
end
function ModuleBInterface()
    res = query("...")
end

其中,ModuleA 要走 HTTP 方法调用 ModuleB 的一个接口。起初,http_request("ModuleBInterface") 使用 lua-socket 为同步阻塞式调用,后来改成了 lua-resty-http 为异步非阻塞式,然后一堆问题就来了。接下来逐步分析:

错误的封装 query_db()

先来分析 query_db() 的细节:

db:connect() and connection pool

其中,db:connect()tcpsock:connect(host, port, options_table?) 实现,其逻辑是:如果连接池中有空闲的连接,那么直接取出一个空闲的连接返回;如果连接池中没有空闲连接,那么就创建一个返回。官方文档如下:

Before actually resolving the host name and connecting to the remote backend, this method will always look up the connection pool for matched idle connections created by previous calls of this method (or the ngx.socket.connect function). (tcpsock:connect)

其中,连接池是使用 connect() 传入的 host + portunix domain socket path 标识的。

db:set_keepalive() and connection pool

其中,db:set_keepalive()tcpsock:setkeepalive(timeout?, size?) 实现,其功能是:将连接放入连接池,可以再次被本 routine 或其他 routine 使用(这里的 routine 可以理解为 coroutine)。官方文档如下:

Puts the current socket’s connection immediately into the cosocket built-in connection pool and keep it alive until other connect method calls request it or the associated maximal idle timeout is expired.

The first optional argument, timeout, can be used to specify the maximal idle timeout (in milliseconds) for the current connection. If omitted, the default setting in the lua_socket_keepalive_timeout config directive will be used. If the 0 value is given, then the timeout interval is unlimited.

The second optional argument, size, can be used to specify the maximal number of connections allowed in the connection pool for the current server (i.e., the current host-port pair or the unix domain socket file path). Note that the size of the connection pool cannot be changed once the pool is created. When this argument is omitted, the default setting in the lua_socket_pool_size config directive will be used.

When the connection pool exceeds the available size limit, the least recently used (idle) connection already in the pool will be closed to make room for the current connection.

Note that the cosocket connection pool is per Nginx worker process rather than per Nginx server instance, so the size limit specified here also applies to every single Nginx worker process. (tcpsock:setkeepalive)

这里有几点澄清:

  • 如果超过 timeout 都没有被取走,则连接将被自动释放;
  • 如果连接池里的连接个数超过 size, 则关闭最空闲的连接;
  • 连接池的有效范围是 Nginx worker, 而非整个 Nginx 实例,这点非常重要。

连接何时关闭

一个连接除了显式关闭放入连接池过期后自动关闭的以外,还有如下关闭方式:

For every cosocket object’s underlying connection, if you do not explicitly close it (via close) or put it back to the connection pool (via setkeepalive), then it is automatically closed when one of the following two events happens:

  • the current request handler completes, or
  • the Lua cosocket object value gets collected by the Lua GC.
    Fatal errors in cosocket operations always automatically close the current connection (note that, read timeout error is the only error that is not fatal), and if you call close on a closed connection, you will get the “closed” error. (ngx.socket.tcp)

原因 - 误用连接池

经过上面的分析,可以知道在执行 http_request("ModuleBInterface") 时,由于发送完 HTTP 请求之后,当前 coroutine 会 yield, 处理该 HTTP 请求的 Nginx worker 很可能就是当前 worker, 如果是同一 worker, ModuleB 在执行 query_db() 时,很可能取到 ModuleA 放回连接池的连接,如果取到同一个连接,那么 ModuleA 执行的 SQL 语句(例如 COMMIT, ROLLBACK)会影响 ModuleB 之前执行的 SQL 语句,即使没有影响,ModuleA 在执行 query("COMMIT") 时也可能取到其他连接。总之,不可预知的风险太多。

疑问

为什么 http_request() 用同步阻塞方式不会有问题?

如果 http_request() 是同步阻塞的,当前进程将被阻塞,那么 ModuleBInterface() 将被另一个 Nginx worker 执行。有上文可知,连接池在多个 worker 之间时不共享的,因此不会有问题。

既然 query_db() 也是异步非阻塞的,为什么多次调用 query_db() 连接不会被其它 routine 抢占?

我们分解一下连续连接调用 query_db() 的过程:

过程分解之后就一目了然了,由于 3 和 9 yield 时连接没有放回 pool 里面,因此,这两步不会出现连接被抢占的情况。而 6 和 7 之前又没有 yield, 程序又是单线程的,在 6 和 7 之前当前 worker 进程不会处理其他 HTTP 请求,因此也不会发生连接被其它 routine 占用的情况,也就是说,6 和 7 是多余的。

从这里也可以看出,只要 6 和 7 之间当前 coroutine 不 yield 连接就不会被其它 routine 取到,即使它被放进了 pool.

相关内容

    暂无相关文章