Rust in Everything
基于 sqlx 0.9.0

获取连接

主线位置:连接池主线的前半段。fetch_one(&pool) 里 &Pool 的 Executor 实现第一句就是 pool.acquire().await?。本页从 Pool::acquire 走到交出一个 PoolConnection:拿许可、弹空闲连接并检查、或者加计数新建连接,以及这一切外面的那个超时。连接用完之后怎么回到池里,在归还、Drop 与维护任务。

flowchart TB
  start["Pool::acquire"] --> closed{池已关闭}
  closed -->|是| pc[PoolClosed]
  closed -->|否| permit["排队拿许可"]
  permit --> pop{"队列有空闲"}
  pop -->|有| chk["检查空闲连接"]
  chk -->|通过| done["交出连接"]
  chk -->|不通过| conn
  pop -->|没有| inc{"能加计数"}
  inc -->|成功| conn["connect 带退避"]
  inc -->|满了| yld["yield_now 再来"]
  yld --> permit
  conn --> done
  permit -.->|超时| pto[PoolTimedOut]

入口:整个循环包在一个超时里

Pool::acquire 克隆一份 Arc<PoolInner>,所以返回的 future 是 'static 的,可以直接交给 tokio::spawn:

    pub fn acquire(&self) -> impl Future<Output = Result<PoolConnection<DB>, Error>> + 'static {
        let shared = self.0.clone();
        async move { shared.acquire().await.map(|conn| conn.reattach()) }
    }

PoolInner::acquire 是本页的主体:

    pub(super) async fn acquire(self: &Arc<Self>) -> Result<Floating<DB, Live<DB>>, Error> {
        if self.is_closed() {
            return Err(Error::PoolClosed);
        }

        let acquire_started_at = Instant::now();
        let deadline = acquire_started_at + self.options.acquire_timeout;

        let acquired = crate::rt::timeout(
            self.options.acquire_timeout,
            async {
                loop {
                    // Handles the close-event internally
                    let permit = self.acquire_permit().await?;


                    // First attempt to pop a connection from the idle queue.
                    let guard = match self.pop_idle(permit) {

                        // Then, check that we can use it...
                        Ok(conn) => match check_idle_conn(conn, &self.options).await {

                            // All good!
                            Ok(live) => return Ok(live),

                            // if the connection isn't usable for one reason or another,
                            // we get the `DecrementSizeGuard` back to open a new one
                            Err(guard) => guard,
                        },
                        Err(permit) => if let Ok(guard) = self.try_increment_size(permit) {
                            // we can open a new connection
                            guard
                        } else {
                            // ...
                            tracing::debug!("woke but was unable to acquire idle connection or open new one; retrying");
                            // ...
                            crate::rt::yield_now().await;
                            continue;
                        }
                    };

                    // Attempt to connect...
                    return self.connect(deadline, guard).await;
                }
            }
        )
            .await
            .map_err(|_| Error::PoolTimedOut)??;
        // ... 慢获取的日志,见下文
        Ok(acquired)
    }

返回值的类型说明了这一页的几个角色:

  • 许可(AsyncSemaphoreReleaser):信号量里的一个名额,总数是 max_connections。拿到许可才有资格碰空闲队列或者开新连接。
  • DecrementSizeGuard:许可「转正」之后的样子,代表池里的一条连接(已有的或将要建的)。它被 drop 时把 size 减一并归还许可。
  • Floating<DB, C>:一条连接 C 加上它的 guard,是连接在「池」和「用户」之间转手时的形态。acquire 交出 Floating<DB, Live<DB>>,Pool::acquire 用 reattach 把它变成 PoolConnection:调 guard.cancel(),许可和计数从此由 PoolConnection 隐式持有,直到它被 drop。

rt::timeout 在 tokio 下就是 tokio::time::timeout(实现见 tokio 卷 Sleep 的一生的 timeout 一节)。超时时内层 future 被 drop:排在信号量里的等待者离开队列,正在进行的 connect 被取消,它手里的 guard 随之 drop,计数减回去。

拿许可:acquire_permit

acquire_permit 对普通的池只有一行有用的:

            close_event.do_until(acquire_self).await

acquire_self 是 self.semaphore.acquire(1),close_event 是池的关闭事件。CloseEvent::do_until 同时等两件事,池先被关闭就返回 Err(Error::PoolClosed):所以 Pool::close 一调用,所有正在排队的 acquire 立刻报 PoolClosed,不用等到超时。

信号量是 AsyncSemaphore,开了 _rt-tokio 时它的字段就是一个 tokio::sync::Semaphore,acquire(1) 调 acquire_many(1)。许可的计数、等待链表、释放时按 FIFO 分给最早的等待者,都是 tokio 的实现,见 tokio 卷的 batch_semaphore。sqlx 在这上面只加了一个约定:许可数 = 「可以借出或新建」的名额。空闲队列里的连接不占许可,借出的连接和正在建的连接各占一个。

先到先得

max_connections(1) 的池,先占住唯一的连接,再起 5 个任务依次排队(每起一个等 10 毫秒,保证它们按顺序进入信号量的队列),然后放手;每个任务拿到后占 20 毫秒(src/bin/fair.rs):

let held = pool.acquire().await?;
let mut tasks = Vec::new();
for i in 0..5 {
  let (pool, order, clock) = (pool.clone(), order.clone(), clock.clone());
  tasks.push(tokio::spawn(async move {
    let conn = pool.acquire().await.unwrap();
    println!("{:>4}ms waiter {i} got it", clock.ms());
    order.lock().unwrap().push(i);
    tokio::time::sleep(Duration::from_millis(20)).await;
    drop(conn);
  }));
  // let each waiter reach the semaphore before the next one starts
  tokio::time::sleep(Duration::from_millis(10)).await;
}

println!("{:>4}ms try_acquire while 5 are queued: {:?}", clock.ms(), pool.try_acquire().map(|_| ()));
drop(held);
println!("{:>4}ms holder dropped", clock.ms());
// a latecomer arriving right after the release
let late = {
  let (pool, clock) = (pool.clone(), clock.clone());
  tokio::spawn(async move {
    let _conn = pool.acquire().await.unwrap();
    println!("{:>4}ms latecomer got it", clock.ms());
  })
};
  59ms try_acquire while 5 are queued: None
  59ms holder dropped
  60ms waiter 0 got it
  83ms waiter 1 got it
 106ms waiter 2 got it
 128ms waiter 3 got it
 151ms waiter 4 got it
 174ms latecomer got it
order: [0, 1, 2, 3, 4]

顺序就是排队的顺序,放手之后才来的任务排在最后。每次交接比 20 毫秒多出 2 到 3 毫秒:上一个任务 drop 连接后,归还任务要先 ping 一个来回、把连接推回队列,才释放许可,下一个等待者才醒(见归还)。fair 选项在 tokio 下改变不了这一点,见 PoolOptions。

Pool::try_acquire 走 PoolInner::try_acquire:semaphore.try_acquire(1) 不排队,有人在排队时 tokio 信号量的计数是 0,所以它拿不到,上面输出的第一行就是这样。它只从空闲队列取,不新建连接,也不经过 check_idle_conn,下文的抓包里能看到它不 ping。

子池:从父池偷许可

有 parent 的子池(#[sqlx::test] 用它)自己的信号量从 0 个许可开始。acquire_permit 在子池还没满(size < max_connections)时同时等两个信号量:

            future::poll_fn(|cx| {
                if close_event.as_mut().poll(cx).is_ready() {
                    return Poll::Ready(Err(Error::PoolClosed));
                }

                if parent_close_event.as_mut().poll(cx).is_ready() {
                    // Propagate the parent's close event to the child.
                    self.mark_closed();
                    return Poll::Ready(Err(Error::PoolClosed));
                }

                if let Poll::Ready(permit) = acquire_self.as_mut().poll(cx) {
                    return Poll::Ready(Ok(permit));
                }

                // Don't try the parent right away.
                if poll_parent {
                    acquire_parent.as_mut().poll(cx).map(Ok)
                } else {
                    poll_parent = true;
                    cx.waker().wake_by_ref();
                    Poll::Pending
                }
            })

第一次 poll 只看自己的信号量,然后叫醒自己、返回 Pending,第二次 poll 起才去父池拿。这样子池里刚归还的许可有机会先被用上,不必每次都从父池偷。父池关闭会传染给子池。

弹出空闲连接并检查

拿到许可后先试空闲队列。pop_idle:

        if let Some(idle) = self.idle_conns.pop() {
            self.num_idle.fetch_sub(1, Ordering::AcqRel);
            Ok(Floating::from_idle(idle, (*self).clone(), permit))
        } else {
            Err(permit)
        }

idle_conns 是 crossbeam 的 ArrayQueue,无锁、容量固定为 max_connections,先进先出,所以最早归还的连接最先被借出。num_idle 是另外维护的原子计数,注释说 ArrayQueue::len() 要等头尾指针停止变化才能算出长度,高并发时可能很慢。from_idle 里 DecrementSizeGuard::from_permit 调 permit.disarm(),在 tokio 下就是 SemaphorePermit::forget():许可不再随 SemaphorePermit 的 drop 自动归还,改由 guard 负责。

check_idle_conn 只做两件事:

    if options.test_before_acquire {
        // Check that the connection is still live
        if let Err(error) = conn.ping().await {
            // an error here means the other end has hung up or we lost connectivity
            // either way we're fine to just discard the connection
            // the error itself here isn't necessarily unexpected so WARN is too strong
            tracing::info!(%error, "ping on idle connection returned error");
            // connection is broken so don't try to close nicely
            return Err(conn.close_hard().await);
        }
    }

    if let Some(test) = &options.before_acquire {
        // ...
    }

    // No need to re-connect; connection is alive or we don't care
    Ok(conn.into_live())

这里不查 max_lifetime。 一条超龄的连接照样可以被借出,要等它归还时或维护任务来关(归还与维护里有实验)。idle_timeout 也不在这里查。

检查不通过时,函数把这条连接关掉,返回它的 DecrementSizeGuard。acquire 拿着这个 guard 直接去 connect:size 不变(坏连接的名额让给新连接),也不再从队列里试下一条空闲连接。before_acquire 的文档说会「tries again with another idle connection」,代码不是这样。两条空闲连接、before_acquire 拒绝第一条(src/bin/reject.rs):

let pool = PgPoolOptions::new()
  .before_acquire(move |_conn, _meta| {
    let flag = flag.clone();
    Box::pin(async move { Ok(!flag.swap(false, Ordering::SeqCst)) })
  })
  .connect_with(lab::opts("pool-reject"))
  .await?;
let mut obs = lab::observer().await;
let (a, b) = (pool.acquire().await?, pool.acquire().await?);
drop((a, b));
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
println!("before: size={} idle={} pids={:?}", pool.size(), pool.num_idle(), lab::backends(&mut obs, "pool-reject").await);
reject_next.store(true, Ordering::SeqCst);
let mut conn = pool.acquire().await?;
let pid: i32 = sqlx::query_scalar("select pg_backend_pid()").fetch_one(&mut *conn).await?;
println!("got pid={pid}: size={} idle={} pids={:?}", pool.size(), pool.num_idle(), lab::backends(&mut obs, "pool-reject").await);

lab::backends 查的是 pg_stat_activity 里 application_name 等于这个池的名字的后端(池的连接参数用 PgConnectOptions::application_name 设了名字):

before: size=2 idle=2 pids=[499, 501]
got pid=502: size=2 idle=1 pids=[501, 502]

499 被拒绝、关掉,队列里的 501 没被碰,借出的是新建的 502。

借出时的 ping

Postgres 的 ping 只发一个 Sync,等回一个 ReadyForQuery。把池接到打印协议消息的代理上(代理代码见一次查询的全过程),借一条连接、查一次、还回去,test_before_acquire 分别为 true 和 false,中间再试一次 try_acquire(src/bin/pings.rs):

for test in [true, false] {
  p.quiet.store(true, Ordering::SeqCst);
  let pool = PgPoolOptions::new()
    .max_connections(1)
    .test_before_acquire(test)
    .connect_with(lab::via_proxy("pool-pings", p.port))
    .await?;
  tokio::time::sleep(Duration::from_millis(100)).await;
  p.quiet.store(false, Ordering::SeqCst);

  println!("--- test_before_acquire({test}) ---");
  let conn = pool.acquire().await?;
  println!("acquired: size={} num_idle={}", pool.size(), pool.num_idle());
  let mut conn = conn;
  let one: i32 = sqlx::query_scalar("select 1").fetch_one(&mut *conn).await?;
  println!("query -> {one}");
  drop(conn);
  println!("dropped:  size={} num_idle={}", pool.size(), pool.num_idle());
  tokio::time::sleep(Duration::from_millis(100)).await;
  println!("later:    size={} num_idle={}", pool.size(), pool.num_idle());

  if test {
    println!("--- try_acquire (test_before_acquire(true)) ---");
    let conn = pool.try_acquire();
    println!("try_acquire -> {:?}", conn.as_ref().map(|_| "PoolConnection"));
    println!("again while held -> {:?}", pool.try_acquire().map(|_| "PoolConnection"));
    drop(conn);
    tokio::time::sleep(Duration::from_millis(100)).await;
  }

  p.quiet.store(true, Ordering::SeqCst);
  pool.close().await;
}
--- test_before_acquire(true) ---
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
acquired: size=1 num_idle=0
  [conn 1] C->S Parse(sqlx_s_1, "select 1"), Describe(S, sqlx_s_1), Sync
  [conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, DataRow(列长 4), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
query -> 1
dropped:  size=1 num_idle=0
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
later:    size=1 num_idle=1
--- try_acquire (test_before_acquire(true)) ---
try_acquire -> Some("PoolConnection")
again while held -> None
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
--- test_before_acquire(false) ---
acquired: size=1 num_idle=0
  [conn 2] C->S Parse(sqlx_s_1, "select 1"), Describe(S, sqlx_s_1), Sync
  [conn 2] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
  [conn 2] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
  [conn 2] S->C BindComplete, DataRow(列长 4), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
query -> 1
dropped:  size=1 num_idle=0
  [conn 2] C->S Sync
  [conn 2] S->C ReadyForQuery(I)
later:    size=1 num_idle=1
  • true 时 acquire 之前有一对 Sync / ReadyForQuery,false 时没有。
  • drop 之后的那一对两种情况都有,那是归还时无条件的 ping。dropped: 那行 num_idle=0、later: 那行才是 1:归还是后台任务做的。
  • try_acquire 借出时没有 ping,只有归还时那一对;连接被借走后再 try_acquire 得到 None,它不会新建连接。

借出时的 ping 保证交出去的连接此刻还活着(服务端没有因为空闲超时、重启、网络中断把它断掉),代价是每次借出多一个来回。关掉它,坏连接要到第一条查询才暴露,那时 acquire 已经返回,不会再替你换一条。

新建连接:try_increment_size 和 DecrementSizeGuard

队列是空的,就试着把 size 加一。try_increment_size:

        let result = self
            .size
            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |size| {
                if self.is_closed() {
                    return None;
                }

                size.checked_add(1)
                    .filter(|size| size <= &self.options.max_connections)
            });

        match result {
            // we successfully incremented the size
            Ok(_) => Ok(DecrementSizeGuard::from_permit((*self).clone(), permit)),
            // the pool is at max capacity or is closed
            Err(_) => Err(permit),
        }

size 先加、连接后建。加上去的那一刻起,guard 就代表这条还不存在的连接;connect 失败、超时被取消、或者任何一步出错让 guard 被 drop,DecrementSizeGuard 的 Drop 把 size 减一并释放许可:

impl<DB: Database> Drop for DecrementSizeGuard<DB> {
    fn drop(&mut self) {
        if !self.cancelled {
            self.pool.size.fetch_sub(1, Ordering::AcqRel);

            // and here we release the permit we got on construction
            self.pool.semaphore.release(1);
        }
    }
}

计数和许可绑在同一个 RAII 对象上,所以没有哪条错误路径能让 size 和真实连接数对不上。pool.size() 包括正在建的连接,归还与维护里 connect_lazy 的实验能看到 size=1 而服务端一条连接都还没有的瞬间。

许可拿到了,却既没有空闲连接也不能新建

try_increment_size 失败时,acquire 打一条 debug 日志,yield_now 一下再回去排队。源码注释说这「can happen for a child pool that's at its connection limit, or if the pool was closed between acquire_permit() and try_increment_size()」,并说「we're likely in the current-thread runtime if it's Tokio, and so we should yield to let any spawned return_to_pool() tasks execute」。

普通的池里,许可数和 max_connections 相等,拿到许可就意味着「借出的 + 正在建的」还没到上限,队列空时 size 一定还能加,这条路径只在关闭的竞态里出现。子池不一样:它的 size 检查在等父池许可之前做,等的时候别的任务可能已经把子池填满了。父池 max_connections(2)、子池 max_connections(1),current_thread 运行时上两个任务同时向子池要连接(src/bin/current_thread.rs 的后半段):

let parent = PgPoolOptions::new()
  .max_connections(2)
  .connect_with(lab::opts("pool-ct"))
  .await?;
let child = PgPoolOptions::new()
  .max_connections(1)
  .parent(parent.clone())
  .connect_lazy_with(lab::opts("pool-ct"));
let (c1, c2) = (child.clone(), child.clone());
let a = tokio::spawn(async move { drop(c1.acquire().await.unwrap()); "a done" });
let b = tokio::spawn(async move { drop(c2.acquire().await.unwrap()); "b done" });
println!("{} / {}", a.await?, b.await?);

RUST_LOG=sqlx_core::pool=debug:

...
DEBUG sqlx_core::pool::inner: woke but was unable to acquire idle connection or open new one; retrying
a done / b done
...

两个任务都从父池偷到了许可;先到的那个把子池的 size 加到 1 去建连接,后到的加不上,走到这条日志,yield_now 后回到循环开头。这一次子池已满,acquire_permit 不再去父池,只等子池自己的信号量,等先到的那个归还后拿到。后到的那个许可在 try_increment_size 返回 Err(permit) 后被 drop,还给了父池。注意这条日志的 target 是模块路径 sqlx_core::pool::inner,不是慢获取日志用的 sqlx::pool::acquire:RUST_LOG=sqlx=debug 按前缀匹配能看到它,RUST_LOG=sqlx::pool=debug 看不到。

connect:重试和退避

PoolInner::connect 拿着 guard 和 acquire 算好的截止时间:

        let mut backoff = Duration::from_millis(10);
        let max_backoff = deadline_as_timeout(deadline)? / 5;

        loop {
            let timeout = deadline_as_timeout(deadline)?;

            // clone the connect options arc so it can be used without holding the RwLockReadGuard
            // across an async await point
            let connect_options = self
                .connect_options
                .read()
                .expect("write-lock holder panicked")
                .clone();

            // result here is `Result<Result<C, Error>, TimeoutError>`
            // if this block does not return, sleep for the backoff timeout and try again
            match crate::rt::timeout(timeout, connect_options.connect()).await {
                // successfully established connection
                Ok(Ok(mut raw)) => {
                    // ... 跑 after_connect,成功就 return Ok(Floating::new_live(raw, guard)),
                    // 出错就 close_hard 然后落到下面的退避
                }

                // an IO error while connecting is assumed to be the system starting up
                Ok(Err(Error::Io(e))) if e.kind() == std::io::ErrorKind::ConnectionRefused => (),

                // We got a transient database error, retry.
                Ok(Err(Error::Database(error))) if error.is_transient_in_connect_phase() => (),

                // Any other error while connection should immediately
                // terminate and bubble the error up
                Ok(Err(e)) => return Err(e),

                // timed out
                Err(_) => return Err(Error::PoolTimedOut),
            }

            // If the connection is refused, wait in exponentially
            // increasing steps for the server to come up,
            // capped by a factor of the remaining time until the deadline
            crate::rt::sleep(backoff).await;
            backoff = cmp::min(backoff * 2, max_backoff);
        }
建连接的结果处理
成功,after_connect 也成功返回
after_connect 出错error! 日志,close_hard,退避后重试
I/O 错误且 kind() == ConnectionRefused退避后重试(注释:「assumed to be the system starting up」)
数据库错误且 is_transient_in_connect_phase退避后重试;Postgres 认 53300(too_many_connections)和 57P03(cannot_connect_now,数据库还在启动)
其他错误(认证失败、TLS、DNS、别的 I/O 错误)立刻返回这个错误
截止时间到了PoolTimedOut

退避从 10 毫秒开始翻倍,上限是进入 connect 时剩余时间的五分之一。deadline_as_timeout 在截止时间已过时直接返回 PoolTimedOut。

用三种对端试(src/bin/connect_fail.rs):本机 1 号端口没人监听,内核直接回 RST;一个假的服务端读完 StartupMessage 就回一条 ErrorResponse,错误码分别是 57P03 和 28P01(密码错误),并记下每次连接到来的时刻。池的 acquire_timeout 是 1 秒:

async fn try_pool(name: &str, port: u16) {
  let started = Instant::now();
  let res = PgPoolOptions::new()
    .acquire_timeout(Duration::from_secs(1))
    .connect_with(opts_at(port))
    .await;
  println!("{name}: after {:?}: {}", started.elapsed(), res.err().map(|e| e.to_string()).unwrap_or_default());
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
  // port 1 on loopback: nothing listens, the kernel answers RST -> ConnectionRefused
  try_pool("closed port", 1).await;

  let t0 = Instant::now();
  let (port, hits) = fake_server("57P03", "the database system is starting up", t0).await;
  try_pool("57P03", port).await;
  println!("  attempts at ms: {:?}", hits.lock().unwrap());

  let t0 = Instant::now();
  let (port, hits) = fake_server("28P01", "password authentication failed for user \"lab\"", t0).await;
  try_pool("28P01", port).await;
  println!("  attempts at ms: {:?}", hits.lock().unwrap());
  Ok(())
}
closed port: after 1.005184208s: pool timed out while waiting for an open connection
...
28P01: after 425.542µs: error returned from database: password authentication failed for user "lab"
  attempts at ms: [0]

57P03 那两行留给下面的动脑筋。

❗IMPORTANT
动脑筋:acquire_timeout 是 1 秒,服务端每次都回 57P03。在报 PoolTimedOut 之前,假服务端一共收到几次连接?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14(假服务端在本机)。答案在页末。

另外两行对照上面的表:关闭的端口每次都是 ConnectionRefused,一直重试到截止时间,最后报的是 PoolTimedOut;28P01 不在可重试的名单里,第一次就原样返回,前后不到半毫秒。

📝NOTE
没有蠢问题

问: 端口根本没开,connect 却等满 1 秒才报 pool timed out while waiting for an open connection。为什么不直接告诉我「连接被拒绝」?

答: 因为 ConnectionRefused 被当成数据库还没起来(注释原话是「assumed to be the system starting up」):与其让应用在数据库启动前的那几秒里直接失败,不如在 acquire_timeout 之内一直重试。代价是重试期间的错误被丢掉了,超时后只剩 PoolTimedOut,和「池满了等不到连接」报的是同一个错误。排查时先看是不是 size() 已经到了 max_connections:没到,多半是连不上;到了,是连接被占着。认证失败这类不会自愈的错误不重试,会立刻原样报出来。

超时:PoolTimedOut

acquire_timeout 包住的是整个循环:排队等许可、ping、before_acquire、建连接和 after_connect,全部加起来不超过它。最常见的超时是池被占满:max_connections(1),自己先占住那条连接,再用 &pool 查询(src/bin/timeout.rs):

let pool = PgPoolOptions::new()
  .max_connections(1)
  .acquire_timeout(Duration::from_millis(1500))
  .connect_with(lab::opts("pool-timeout"))
  .await?;

let held = pool.acquire().await?;
println!("holding: size={} num_idle={}", pool.size(), pool.num_idle());

for _ in 0..3 {
  let started = Instant::now();
  let res = sqlx::query_scalar::<_, i32>("select 1").fetch_one(&pool).await;
  let err = res.unwrap_err();
  println!("after {:?}: {err} / {err:?}", started.elapsed());
}

drop(held);
let started = Instant::now();
let one: i32 = sqlx::query_scalar("select 1").fetch_one(&pool).await?;
println!("released, query -> {one} after {:?}", started.elapsed());
holding: size=1 num_idle=0
after 1.500789042s: pool timed out while waiting for an open connection / PoolTimedOut
after 1.501650375s: pool timed out while waiting for an open connection / PoolTimedOut
after 1.501728792s: pool timed out while waiting for an open connection / PoolTimedOut
released, query -> 1 after 3.001542ms

每次都是 1.5 秒加不到 2 毫秒,就是 tokio::time::timeout 的精度。这是「持有连接时再 acquire」的最小形式:同一个任务里先 pool.acquire() 或 pool.begin() 拿着一条,再把 &pool 当执行器用,池小于并发需要时就会卡到超时。错误本身的类型见错误。

慢获取的日志

acquire 成功后看一眼用了多久:

        let acquired_after = acquire_started_at.elapsed();

        let acquire_slow_level = self
            .acquire_slow_level
            .filter(|_| acquired_after > self.options.acquire_slow_threshold);

        if let Some(level) = acquire_slow_level {
            private_tracing_dynamic_event!(
                target: "sqlx::pool::acquire",
                level,
                acquired_after_secs = acquired_after.as_secs_f64(),
                slow_acquire_threshold_secs = self.options.acquire_slow_threshold.as_secs_f64(),
                "acquired connection, but time to acquire exceeded slow threshold"
            );
        } else if let Some(level) = self.acquire_time_level {
            private_tracing_dynamic_event!(
                target: "sqlx::pool::acquire",
                level,
                acquired_after_secs = acquired_after.as_secs_f64(),
                "acquired connection"
            );
        }

超过 acquire_slow_threshold(默认 2 秒)用 acquire_slow_level(默认 Warn),否则用 acquire_time_level(默认 Off,不打)。两个级别在 new_arc 里从 log::LevelFilter 换成 Option<tracing::Level>,Off 就是 None。超时的那次 acquire 已经在 ? 处返回了错误,不打这条日志。

把阈值调到 200 毫秒,让另一个任务占住连接 500 毫秒(src/bin/slow.rs):

let pool = PgPoolOptions::new()
  .max_connections(1)
  .acquire_slow_threshold(Duration::from_millis(200))
  .connect_with(lab::opts("pool-slow"))
  .await?;

println!("--- fast acquire ---");
drop(pool.acquire().await?);
tokio::time::sleep(Duration::from_millis(100)).await;

println!("--- someone holds the connection for 500ms ---");
let held = pool.acquire().await?;
tokio::spawn(async move {
  tokio::time::sleep(Duration::from_millis(500)).await;
  drop(held);
});
drop(pool.acquire().await?);

RUST_LOG=sqlx=debug:

--- fast acquire ---
--- someone holds the connection for 500ms ---
 WARN sqlx::pool::acquire: acquired connection, but time to acquire exceeded slow threshold acquired_after_secs=0.503702334 slow_acquire_threshold_secs=0.2

快的那次什么都不打。acquired_after_secs 里包括了排队、ping 和可能的建连接,单看它分不出是哪一段慢;配合 pool.size() 和 pool.num_idle() 看:size 等于 max_connections 且 num_idle 为 0,是连接都被借走了。

📝NOTE
冷知识:这个字段在 0.8 里叫 aquired_after_secs

慢获取日志是 #3073(0.8.0)加的,字段名少了一个 c:0.8.6 的 inner.rs 里两处都写着 aquired_after_secs。#3486 改成 acquired_after_secs,首次发布于 0.9.0。PR 说明提到改名的顾虑:「Some users of the crate were interested in collecting metrics or building alerts based on this and related tracing fields. If we simply rename the field, this would be a breaking change.」最后还是直接改了,CHANGELOG 把它列在 0.9.0 的 Breaking 一节:「Breaking behavior change: implementations parsing tracing logs from SQLx will need to update the spelling.」按字段名做告警的,升级到 0.9 要跟着改。

动脑筋答案

connect_fail 的完整输出:

closed port: after 1.005184208s: pool timed out while waiting for an open connection
57P03: after 1.003278709s: pool timed out while waiting for an open connection
  attempts at ms: [0, 14, 38, 82, 165, 329, 532, 736, 939]
28P01: after 425.542µs: error returned from database: password authentication failed for user "lab"
  attempts at ms: [0]

9 次。按源码算:进入 connect 时剩余约 1 秒,max_backoff 约 200 毫秒;退避依次是 10、20、40、80、160,然后一直是 200,于是理论上的尝试时刻是 0、10、30、70、150、310、510、710、910 毫秒,下一次在 1110 毫秒,已经超过截止时间。实测每次多出几毫秒(每次建 TCP 连接、读写一条消息的时间累积起来),第 9 次在 939 毫秒。第 10 次之前的那次 sleep 被外层的 rt::timeout 打断,报 PoolTimedOut,服务端回的 cannot_connect_now 不会出现在错误里。

小结

  • acquire 的全部工作包在一个 acquire_timeout 里:排队、ping、before_acquire、建连接、after_connect。超时一律是 PoolTimedOut,池关闭时排队者立刻得到 PoolClosed。
  • 许可数等于 max_connections,借出和正在建的连接各占一个,空闲连接不占。tokio 的信号量保证先到先得;try_acquire 有人排队时拿不到,也不新建连接、不 ping。
  • 空闲连接先进先出地弹出,借出前只做 ping(test_before_acquire)和 before_acquire,不查 max_lifetime 和 idle_timeout;检查不过就关掉,用同一个名额新建,不试下一条空闲连接。
  • size 先加后连,DecrementSizeGuard 保证任何失败路径都把计数和许可还回去。
  • 连接被拒和 53300 / 57P03 会从 10 毫秒起指数退避重试,上限是剩余时间的五分之一;其他错误立刻返回。连不上库最后看到的只是 PoolTimedOut。
  • 慢于 acquire_slow_threshold(2 秒)打一条 WARN,target 是 sqlx::pool::acquire,字段 acquired_after_secs 在 0.9 之前拼错了。

💬 关联讨论

发起讨论
加载中...