Logo

Rails 数据库异步查询探索

avatar jane 27 Oct 2026

本文带你深入了解 Rails load_async 和 Ruby async 两种不同的异步查询方案和实现原理,并回答一个容易踩坑的问题:数据库连接池数 pool配多大。

一、环境和数据准备

Ruby 3.3.6
Rails 8.0.5.1
PostgreSQL 16.10
pg gem 1.7.0
async gem 2.21.1

SQLite3 受限于其单文件数据库和锁机制,不适合本文并发查询的场景。PostgreSQL 采用服务端进程架构,客户端通过连接与服务端进程通信,支持多个连接并发查询。

数据准备:创建 1000 万条订单记录(耗时约 5 分钟)。

count = 10,000,000
ActiveRecord::Base.connection.execute(<<~SQL)
  TRUNCATE orders;
  INSERT INTO orders (user_id, product_id, quantity, status, created_at, updated_at)
  SELECT (1 + random()*1000)::int,
          (1 + random()*100)::int,
          (1 + random()*10)::int,
          (random()*4)::int,
          now() - ((random()*730)::int || ' days')::interval,
          now()
  FROM generate_series(1, #{count});
  ANALYZE orders;
SQL

二、Rails load_async 后台线程池异步执行

1. 执行查询示例查看效果

config/application.rb 添加配置:

config.active_record.async_query_executor = :global_thread_pool # 进程内共享一个线程池
config.active_record.global_executor_concurrency = 4   # 最大并发线程数,默认就是 4

Rails console 中执行:

def get_purchase_info_with_load_async
  start_time = Time.now

  # 一口气调度 4 条查询,互不等待;返回 ActiveRecord::Promise
  count_promise = Order.paid.async_count
  sum_promise   = Order.paid.async_sum(:quantity)
  avg_promise   = Order.paid.async_average(:quantity)
  top10_promise = Order.paid.group(:product_id).order(Arel.sql("SUM(quantity) DESC")).limit(10)
                         .async_pluck(:product_id, Arel.sql("SUM(quantity)"))

  # 依次取结果;首次 .value 会阻塞到该查询完成
  total_orders_count    = count_promise.value
  total_sales_quantity  = sum_promise.value
  avg_sales_quantity    = avg_promise.value
  top_10_sales_products = top10_promise.value

  puts "load_async 完成,总耗时 #{((Time.now - start_time) * 1000).round(2)} 毫秒"
end
# Rails + Puma 下,每个 HTTP request/Job 都有一层 ActionDispatch::Executor 包起来
# load_async 需要在 Executor 内执行,Rails console 中需要手动添加 Executor
Rails.application.executor.wrap do
# uncached 避免 Rails 缓存,影响测试结果
  ActiveRecord::Base.uncached { get_purchase_info_with_load_async }
end

输出日志:

ASYNC Order Count (1118.7ms) (db time 1122.2ms)  SELECT COUNT(*) FROM "orders" WHERE "orders"."status" = 1 /*application='AsynchronousProgramming'*/
  ASYNC Order Sum (0.0ms) (db time 1117.4ms)  SELECT SUM("orders"."quantity") FROM "orders" WHERE "orders"."status" = 1 /*application='AsynchronousProgramming'*/
  ASYNC Order Average (0.0ms) (db time 1117.5ms)  SELECT AVG("orders"."quantity") FROM "orders" WHERE "orders"."status" = 1 /*application='AsynchronousProgramming'*/
  ASYNC Order Pluck (0.0ms) (db time 1118.6ms)  SELECT "orders"."product_id", SUM(quantity) FROM "orders" WHERE "orders"."status" = 1 GROUP BY "orders"."product_id" ORDER BY SUM(quantity) DESC LIMIT 10 /*application='AsynchronousProgramming'*/
load_async 完成,总耗时 1131.36 毫秒

总执行时长约等于最慢一句的 db time

2. Rails 内部如何实现 load_async

由默认插件 concurrent-ruby 提供的 Concurrent::ThreadPoolExecutor 创建一个最大只能有 4 个线程的线程池执行器:

# activerecord-8.0.5.1/lib/active_record.rb:286-294
def self.global_thread_pool_async_query_executor # :nodoc:
  concurrency = global_executor_concurrency || 4
  @global_thread_pool_async_query_executor ||= Concurrent::ThreadPoolExecutor.new(
    min_threads: 0,
    max_threads: concurrency,
    max_queue: concurrency * 4,
    fallback_policy: :caller_runs
  )
end

执行器中分配线程,启动异步查询:

# activerecord-8.0.5.1/lib/active_record/connection_adapters/abstract/connection_pool.rb:700-703
def schedule_query(future_result) # :nodoc:
  @async_executor.post { future_result.execute_or_skip }
  Thread.pass
end

# `@async_executor.post` 调用链:
# @async_executor.post                              concurrent-ruby/concurrent/executor/ruby_executor_service.rb:17-31
# └─ Concurrent::RubyThreadPoolExecutor#ns_execute  concurrent-ruby/concurrent/executor/ruby_thread_pool_executor.rb:178-187
#    └─ Concurrent::RubyThreadPoolExecutor#ns_assign_worker
#       └─ Concurrent::RubyThreadPoolExecutor#ns_add_busy_worker
#          └─ Worker.new
#             └─ Worker#create_worker

# concurrent-ruby-1.3.8/lib/concurrent-ruby/concurrent/executor/ruby_thread_pool_executor.rb:356-379
def create_worker(queue, pool, idletime)
  Thread.new(queue, pool, idletime) do |my_queue, my_pool, my_idletime|
  ...

# activerecord-8.0.5.1/lib/active_record/future_result.rb:100-106
def execute_or_skip
  return unless pending?

  @session.synchronize do
    return unless pending?

    @pool.with_connection do |connection|
    ...

三、Ruby async gem 单线程内多纤程并发执行

纤程(Fiber)是一种最轻量化的用户态线程。它完全由应用程序在用户空间自主调度(采用协作式多任务),操作系统内核对此不可见、也不参与其调度。

1. 执行同样的数据库查询示例

config/application.rb 添加配置:

# fiber 粒度:每个 fiber 可以有自己的 db 连接。
config.active_support.isolation_level = :fiber
require "async"
require "async/semaphore"
require "async/barrier"

def get_purchase_info_concurrently
  start_time = Time.now
  semaphore = Async::Semaphore.new(4)   # 一个线程内最多 4 个 fiber 同时查库
  barrier   = Async::Barrier.new
  results   = {}

  Async do
    {
      count: -> { Order.paid.count },
      sum:   -> { Order.paid.sum(:quantity) },
      avg:   -> { Order.paid.average(:quantity) },
      top10: -> { Order.paid.group(:product_id).order(Arel.sql("SUM(quantity) DESC")).limit(10)
                         .pluck(:product_id, Arel.sql("SUM(quantity)")).to_h }
    }.each do |label, query|
      barrier.async do
        semaphore.acquire do
          results[label] = query.call
        end
      end
    end
    barrier.wait
  end

  puts "并发完成,总耗时 #{((Time.now - start_time) * 1000).round(2)} 毫秒"
  results
end
ActiveRecord::Base.uncached { get_purchase_info_concurrently }

输出日志:

Order Count (1258.2ms)  SELECT COUNT(*) FROM "orders" WHERE "orders"."status" = 1 /*application='AsynchronousProgramming'*/
  Order Average (1253.4ms)  SELECT AVG("orders"."quantity") FROM "orders" WHERE "orders"."status" = 1 /*application='AsynchronousProgramming'*/
  Order Sum (1258.7ms)  SELECT SUM("orders"."quantity") FROM "orders" WHERE "orders"."status" = 1 /*application='AsynchronousProgramming'*/
  Order Pluck (1256.0ms)  SELECT "orders"."product_id", SUM(quantity) FROM "orders" WHERE "orders"."status" = 1 GROUP BY "orders"."product_id" ORDER BY SUM(quantity) DESC LIMIT 10 /*application='AsynchronousProgramming'*/
并发完成,总耗时 1284.47 毫秒

总执行时长同样约等于最慢一句的 db time。

2. 原理

  • barrier.async 会创建 fiber 对象
async-2.21.1/lib/async/barrier.rb:43 Async::Barrier#async
└─ lib/async/task.rb:219 Async::Task#async
   └─ lib/async/task.rb:192 Async::Task#run
      └─ lib/async/task.rb:432 Async::Task#schedule
# lib/async/task.rb:433
def schedule(&block)
  @fiber = Fiber.new(annotation: self.annotation) do
  ...
  • Async do ... end 在当前线程上启动一个 reactor,reactor 进行事件循环和调度可运行的 fiber。当某个查询等数据库返回时, 如果底层 socket 当前没有数据可读,Async 会通过 io_wait 将当前 fiber 挂起,并由 reactor 继续调度其他 fiber;当该 socket 后续变为可读时,reactor 再恢复对应 fiber —— 并发发生在 IO 等待的间隙里。
# async-2.21.1/lib/kernel/async.rb:24-39
def Async(...)
  ...
  # This calls Fiber.set_scheduler(self):
  reactor = ::Async::Reactor.new
  begin
    return reactor.run(...)
  ...

# async-2.21.1/lib/async/scheduler.rb:492-500   阻塞当前线程跑事件循环
def run(...)
  initial_task = self.async(...) if block_given?
  self.run_loop { run_once }
  ...

# async-2.21.1/lib/async/scheduler.rb:272
# Wait for the specified IO to become ready for the specified events.
def io_wait(io, events, timeout = nil)
  ...
  • 从以下源码可以看出,分配连接的粒度由 isolation_level 决定,它有两个取值::thread (默认) 和 :fiber。默认 :thread 粒度下,同一个线程中的 fiber 共享该线程对应的 connection lease;设置为 :fiber 后,不同 fiber 可以拥有各自的 connection lease。
# activesupport-8.0.5.1/lib/active_support/isolated_execution_state.rb:24-28
module ActiveSupport
  module IsolatedExecutionState # :nodoc:
    class << self
      def isolation_level=(level)
        ···
        @scope =
          case level
          when :thread; Thread
          when :fiber; Fiber
          end
        ···
      end
      ...
      def context
        scope.current
      end
      ...
      self.isolation_level = :thread
    end
  end
end
# activerecord-8.0.5.1/lib/active_record/connection_adapters/abstract/connection_pool.rb:713-715
def connection_lease
  @leases[ActiveSupport::IsolatedExecutionState.context]
end

# activerecord-8.0.5.1/lib/active_record/connection_adapters/abstract/connection_pool.rb:409-428
def with_connection(prevent_permanent_checkout: false)
  lease = connection_lease # key 是 IsolatedExecutionState.context
  sticky_was = lease.sticky
  lease.sticky = false if prevent_permanent_checkout

  if lease.connection
    ...
  else
    begin
      yield lease.connection = checkout
    ensure
      lease.sticky = sticky_was if prevent_permanent_checkout && !sticky_was
      release_connection(lease) unless lease.sticky
    end
  end
end
  • with_connection 在非 sticky 的情况下,会在结束时执行 release_connection 归还连接,无需手动处理。

四、如何配置连接池 pool

两种异步查询方式,对 db pool 有何要求呢?让我们先看下 pool 相关源码:

# activerecord-8.0.5.1/lib/active_record/connection_adapters/abstract/connection_pool.rb:866-887
def acquire_connection(checkout_timeout)
  if conn = @available.poll || try_to_checkout_new_connection   # 先拿空闲连接, 没有才尝试新建
    conn
  else
    reap                                                        # 回收死线程/纤程上的连接
    if conn = @available.poll || try_to_checkout_new_connection
      conn
    else
      @available.poll(checkout_timeout)                         # 还拿不到 → 排队等归还(下面 queue.rb)
    end
  end
end

# activerecord-8.0.5.1/lib/active_record/connection_adapters/abstract/connection_pool.rb:903-911
def try_to_checkout_new_connection
  ...
  if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @size
    @now_connecting += 1    # 未达 @size(即 pool)才建新连接
  ...

# activerecord-8.0.5.1/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb:71-78
def poll(timeout = nil)
  synchronize { internal_poll(timeout) }
end

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

# activerecord-8.0.5.1/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb:111-133
def wait_poll(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
  • 可以看出,如果请求连接时,连接池中的连接都已被占用,且在 checkout_timeout 内没有连接释放,就会抛出异常 ConnectionTimeoutError。在并发查询情况下需要特别 pool 数量的配置。
  • db pool 取决于同一进程/执行上下文中同时占用连接的最大并发量,对于普通线程模型,一般设置为单进程内最大线程数 RAILS_MAX_THREADS。如果代码中用到两种异步查询方式:
      1. Rails load_async 会额外添加新的线程来进行数据库查询,如果按最坏情况估算,load_async 的查询线程全部同时占用连接,则:pool = ENV['RAILS_MAX_THREADS'] + config.active_record.global_executor_concurrency。
      1. Ruby async 每个纤程都可以获取一条连接,如果每个线程最多同时运行 N 个需要独立数据库连接的 fiber,则按最坏情况估算,pool 至少按 pool = ENV['RAILS_MAX_THREADS'] * (1 + N) 配置。

五、总结

  async gem(fiber) load_async(线程池)
并发单位 Fiber (跑在调用线程上) 后台线程
前置配置 isolation_level = :fiber async_query_executor = :global_thread_pool
pool 配置 RAILS_MAX_THREADS * (1 + 并发 fiber 数) RAILS_MAX_THREADS + global_executor_concurrency
事务内执行 子 fiber 用独立连接, 不在外层事务里 抛异常 ActiveRecord::AsynchronousQueryInsideTransactionError

Tags
Async
Ruby On Rails
Book Free 30-Min Call