ActiveRecord 的数据库配置中有一项 pool 值,默认是 5。这个值有什么用处呢?以及应该怎么配置?另外你是不是遇到过 ActiveRecord::ConnectionTimeoutError 异常?本文会带你彻底搞懂这些问题。

配置作用以及如何配置

先说答案,这个值的作用是设置连接池容量(也就是限制应用使用的最大数据库连接数)。配置合理值应该等于你应用最大并发数量,而应用最大的并发数量等于 “进程里配置的最大线程数” 加上 “多线程代码的最大并发数”。

这样描述比较生硬,我举个例子,比如你的应用(例如 puma)配置为:

workers 4
threads 1, 16

Sidekiq 配置为:

concurrency 24

应用代码里有并发代码:

# 下面 10 只是举例,具体你需要大概估量一下,你应用里多线程代码的最大并发数
10.times do
  Thread.new { xxx }
end

那么 pool 最终配置应该是:[16, 24].max + 10 = 34。其中应用配置里的 workers 属于进程,是独立的连接池,是不用管的。

继续深入,如果我不配置相等,而是配置更大的值可以吗?

这个问题可以这么思考,如果你是为了预留后面应用可能出现更大的并发代码(比如上面又出现了比上面 10 更大的情况),那么你可以先预设个更大的配置,这是没问题的;

如果你是想着我数据库配置强,设个更大的数,性能应该会有提升吧,那就想错了,具体可以看下面的 ActiveRecord 对数据库连接池的源码分析。

ActiveRecord 对数据库连接池的源码分析

下面是精简的处理过程源码,主要解释都写在代码里面。另外最后参考里会给出源码链接。

def connection
  # 如果当前线程没有连接,则请求一个(可能是创建,也可能是在空闲连接里面找一个)
  @thread_cached_conns[connection_cache_key(current_thread)] ||= checkout
end

def checkout(checkout_timeout = @checkout_timeout)
  checkout_and_verify(acquire_connection(checkout_timeout))
end

def acquire_connection(checkout_timeout)
  # 先看可用连接里有没,没有的话就执行创建一个新的,创建新的连接会验证总数量(这个就是本文所讲的 `pool` 配置)
  if conn = @available.poll || try_to_checkout_new_connection
    conn
  # 连接总数量验证失败,即连接池里连接数量已经满了时
  else
    # 尝试回收失活的连接
    reap
    # 这个 `poll` 带了超时参数,比起上面无参数的 `poll` 会多一个超时等待逻辑,
    # 就是这里会抛出开头提到的 `ConnectionTimeoutError` 异常
    @available.poll(checkout_timeout)
  end
end

def try_to_checkout_new_connection
  do_checkout = synchronize do
    if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @size
      @now_connecting += 1
    end
  end
  # 连接数超了,下面的 if 块不会执行,也就是返回 `nil`
  if do_checkout
    # ...
  end
end

# 我们下面只关注 `timeout` 参数存在的逻辑
def poll(timeout = nil)
  synchronize { internal_poll(timeout) }
end

def internal_poll(timeout)
  no_wait_poll || (timeout && wait_poll(timeout))
end

def wait_poll(timeout)
  @num_waiting += 1

  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  elapsed = 0
  loop do
    ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
      @cond.wait(timeout - elapsed)
    end

    return remove if any?

    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
    # 这里就是实际抛出异常的代码。超时时间可在应用里配置,配置项为 `checkout_timeout`(代码或文档里可以找到)
    if elapsed >= timeout
      msg = "could not obtain a connection from the pool within %0.3f seconds (waited %0.3f seconds); all pooled connections were in use" %
        [timeout, elapsed]
      raise ConnectionTimeoutError, msg
    end
  end
ensure
  @num_waiting -= 1
end

捋清并分析这个核心过程,可以发现连接数配的超过最大并发数,会在连接数已经达到并发数时不会回收而是继续创建新连接。正常来说创建新连接 do_checkout性能消耗只会比回收旧连接 reap 更高,因此在保证连接数 pool 配置够用的情况下,要尽量小,也就是说理论上等于最好了。

要注意临时任务

当你要编写一个要操作数据库的 task 大任务时,如果你想利用高并发加快速度,比如你打算起 1000 个线程,那么记得要在任务进程里将 ActiveRecord 的 pool 配置成 1000,否则将会产生两种你不希望的情况:

  1. 任务执行过程出现 ActiveRecord::ConnectionTimeoutError 异常;
  2. 任务执行没有异常,但是速度不升反降(出现大量线程等待连接池的消耗)。

实际上这里也是能套用一开始给出的公式的,只是这里 “多线程代码的最大并发数” 是临时的,因此可能会被忽视,忘记同步配置 ActiveRecord 的 pool。扩展一下,所有这种临时情况都需要注意这个。

参考