Rails 数据库异步查询探索
本文带你深入了解 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。如果代码中用到两种异步查询方式:
-
- Rails load_async 会额外添加新的线程来进行数据库查询,如果按最坏情况估算,load_async 的查询线程全部同时占用连接,则:
pool = ENV['RAILS_MAX_THREADS'] + config.active_record.global_executor_concurrency。
- Rails load_async 会额外添加新的线程来进行数据库查询,如果按最坏情况估算,load_async 的查询线程全部同时占用连接,则:
-
- Ruby async 每个纤程都可以获取一条连接,如果每个线程最多同时运行 N 个需要独立数据库连接的 fiber,则按最坏情况估算,pool 至少按
pool = ENV['RAILS_MAX_THREADS'] * (1 + N)配置。
- Ruby async 每个纤程都可以获取一条连接,如果每个线程最多同时运行 N 个需要独立数据库连接的 fiber,则按最坏情况估算,pool 至少按
-
五、总结
| 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