Rust in Everything
基于 sqlx 0.9.0

connect_lazy 设了 min_connections,连接数却一直是 0

主线位置:连接池主线之外的后台维护任务。这个任务按什么周期跑、什么时候调补连接的函数,见归还、Drop 与维护任务;connect 和 connect_lazy 的区别见PoolOptions 与默认值。本页只讲这个组合的现象和绕法。

flowchart TB
  lazy["connect_lazy"] --> spawn["维护任务第一轮"]
  spawn --> empty{"空闲队列为空"}
  empty -->|是| skip["循环体不执行"]
  skip --> sleep["睡 10 分钟"]
  sleep --> reap{"关掉了超时连接吗"}
  reap -->|没有| skip
  reap -->|有| fill["补到 min_connections"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0";换成 =0.8.6 输出逐字相同。

现象

想让池子启动时不连库(数据库晚起也不影响进程启动),又想有几条预热好的连接,于是写:

let pool = PgPoolOptions::new().min_connections(3).connect_lazy_with(opts);

connect_lazy_with 的文档说「If min_connections is set, a background task will be spawned to optimistically establish that many connections for the pool」。实际上 pool.size() 一直是 0;第一次借用之后是 1,再也不会到 3。没有报错,也没有日志。

最小复现

src/bin/minconn.rs,每种池建好后在几个时刻看 size()、num_idle(),和 pg_stat_activity 里这个池的 application_name 对应的后端数:

let pool = PgPoolOptions::new().min_connections(3).connect_lazy_with(opts("pit3-lazy")?);
watch(&mut obs, "lazy 默认超时    ", &pool, "pit3-lazy", &[0, 1000, 3000]).await?;
drop(pool.acquire().await?);
watch(&mut obs, "lazy 借一次之后  ", &pool, "pit3-lazy", &[200, 3000]).await?;
pool.close().await;

let pool = PgPoolOptions::new()
  .min_connections(3)
  .idle_timeout(Duration::from_secs(1))
  .connect_lazy_with(opts("pit3-lazy1s")?);
watch(&mut obs, "lazy idle 1s     ", &pool, "pit3-lazy1s", &[0, 1500]).await?;
drop(pool.acquire().await?);
watch(&mut obs, "idle 1s 借一次后 ", &pool, "pit3-lazy1s", &[200, 1500, 2500]).await?;
pool.close().await;
lazy 默认超时     t=   0ms size=0 num_idle=0 backends=0
lazy 默认超时     t=1000ms size=0 num_idle=0 backends=0
lazy 默认超时     t=3000ms size=0 num_idle=0 backends=0
lazy 借一次之后   t= 200ms size=1 num_idle=1 backends=1
lazy 借一次之后   t=3000ms size=1 num_idle=1 backends=1
lazy idle 1s      t=   0ms size=0 num_idle=0 backends=0
lazy idle 1s      t=1500ms size=0 num_idle=0 backends=0
idle 1s 借一次后  t= 200ms size=1 num_idle=1 backends=1
idle 1s 借一次后  t=1500ms size=1 num_idle=0 backends=0
idle 1s 借一次后  t=2500ms size=3 num_idle=3 backends=3

把 idle_timeout 调到 1 秒看得最清楚:没有连接时,周期任务跑多少轮都不补;借过一条、等它空闲超时被关掉,下一刻才补到 3 条。默认 idle_timeout 是 10 分钟,也就是说默认配置下,至少要等一条连接闲置 10 分钟被回收,min_connections 才第一次生效。

原因

connect_lazy_with 只建 PoolInner,注释写着:

pub fn connect_lazy_with(self, options: <DB::Connection as Connection>::Options) -> Pool<DB> {
    // `min_connections` is guaranteed by the idle reaper now.
    Pool(PoolInner::new_arc(self, options))
}

「idle reaper」就是 spawn_maintenance_tasks 起的周期任务。它每轮 for _ in 0..pool.num_idle() 逐条检查空闲连接,只有某条超时被关掉之后才调 min_connections_maintenance。队列是空的,循环体一次都不执行,什么都不补。借出再归还成功的连接也不触发补连接(归还任务只在连接被关掉时补)。所以 connect_lazy 之后只剩两条路会补:某条连接被关掉,或者 idle_timeout 和 max_lifetime 都设成 None——那时没有周期任务,new_arc 改为 spawn 一个只跑一次的补连接任务。

这条注释和文档在 0.7.3 里就是这样写的,那时周期任务每轮无条件调一次 min_connections_maintenance,按那时的源码,懒加载的池在第一轮就会去补。#3065(首次发布于 0.7.4)为了让超龄连接在维护任务里逐条替换,把这次调用挪进了「关掉了一条」的分支,注释和文档没有跟着改。来龙去脉见归还、Drop 与维护任务的冷知识。用 gh search issues -R transact-rs/sqlx 搜 connect_lazy、lazy pool、min_connections lazy、min_connections not,未找到报告这个问题的 issue。

修法

能连就用 connect。 PoolOptions::connect_with 在返回之前自己调 try_min_connections,返回时已经是 3 条。代价是数据库连不上时建池就失败(最多等 acquire_timeout)。

要懒加载,就自己预热。 同时借够 min_connections 条,再一起还回去;之后这几条连接由维护任务按 idle_timeout / max_lifetime 替换,数量保持住:

async fn warm_up(pool: &PgPool, n: u32) -> Result<(), sqlx::Error> {
  let mut held = Vec::new();
  for _ in 0..n {
    held.push(pool.acquire().await?);
  }
  Ok(())
}
lazy + warm_up(3) t= 200ms size=3 num_idle=3 backends=3
lazy + warm_up(3) t=3000ms size=3 num_idle=3 backends=3
connect           t=   0ms size=3 num_idle=3 backends=3
connect           t=3000ms size=3 num_idle=3 backends=3

必须同时持有:上面「借一次之后」只借了一条,池里就一直是 1 条。

把 idle_timeout 和 max_lifetime 都设成 None 也能让懒加载的池补满,但连接从此永不回收,换来的问题比解决的多,不推荐。

怎么确认自己没中招

  • 搜 connect_lazy 和 min_connections 同时出现的地方。
  • 启动后、有流量之前看 pool.size(),或者在库里 select count(*) from pg_stat_activity where application_name = '<你的名字>'(先用 PgConnectOptions::application_name 给池起个名字)。是 0,就是这个坑。

小结

  • connect_lazy + min_connections 在默认超时下不预热:维护任务只在关掉一条连接之后补连接,空池永远不补,借一次之后也只有 1 条。
  • 文档和 connect_lazy_with 的注释描述的是 0.7.3 及以前的行为;#3065(0.7.4)之后不再成立,0.8.6 和 0.9.0 相同。
  • 修法:用 connect;或懒加载之后自己同时借够 min_connections 条再还回去。

💬 关联讨论

发起讨论
加载中...