Rust in Everything
基于 sqlx 0.9.0

超时取消 pool.begin():连接带着事务回到池里

主线位置:连接池主线。pool.begin() 先 acquire 一条连接,再在上面发 BEGIN;future 在等 BEGIN 回应时被丢掉,PoolConnection 被 drop,走归还那一段:return_to_pool 里的 ping 把连接判为健康,放回空闲队列。归还的顺序见归还、Drop 与维护任务,begin 的两层守卫见事务的「取消和失败:open 先于 BEGIN」。

sequenceDiagram
  participant App as 应用
  participant Conn as PgConnection
  participant PG as Postgres
  App->>Conn: timeout 包着 pool.begin()
  Conn->>PG: Query BEGIN
  App--xConn: 超时 future 被丢掉
  Note over Conn: 深度仍是 0 守卫不排 ROLLBACK
  Conn->>PG: 归还时 ping 发 Sync
  PG-->>Conn: ReadyForQuery T
  Note over Conn: ping 不看状态 连接回池
  App->>Conn: 下一次 insert
  PG-->>Conn: ReadyForQuery T 写入悬空

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0";同一个程序换成 sqlx = "=0.8.6",结果相同(只是 0.8.6 的报错末尾没有 at line N)。

现象

数据库变慢的那几分钟里,有一些 pool.begin() 撞上了调用方设的超时。超时本身被正确处理了,可是之后一部分不在事务里的写入返回 Ok,别的连接却看不到;过一阵,同一批请求开始在毫不相干的地方报:

error returned from database: current transaction is aborted, commands ignored until end of transaction block at line 1750

pg_stat_activity 里应用的某条连接一直是 idle in transaction。

最小复现

src/bin/cancelled_begin.rs。池只有一条连接,经过一个 TCP 代理:代理打印每条协议消息(一次查询的全过程 里那个),并且可以把服务端到客户端的每次读延迟 300 毫秒,模拟一个慢的服务端:

p.delay_ms.store(300, Ordering::SeqCst);
let r = tokio::time::timeout(Duration::from_millis(timeout_ms), pool.begin()).await;
println!("timeout({timeout_ms}ms, pool.begin()) = {:?}", r.map(|r| r.map(|_| ())));
p.delay_ms.store(0, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(500)).await;

sqlx::query("insert into pit2.items values (1)").execute(&pool).await?;
// 另一条连接查 pg_stat_activity.state 和 count(*)
let e = sqlx::query("insert into pit2.items values (1)").execute(&pool).await;
let e = sqlx::query_scalar::<_, i32>("select 1").fetch_one(&pool).await;
pool.close().await;

acquire 时的 ping 要 300 毫秒,BEGIN 的来回又是 300 毫秒,450 毫秒的超时正好落在等 BEGIN 回应的时候:

--- 默认配置(test_before_acquire 默认开) ---
(慢之前)select 1 = Ok
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
  [conn 1] C->S Query("BEGIN")
timeout(450ms, pool.begin()) = Err(Elapsed(()))
  [conn 1] C->S Sync
  [conn 1] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
  [conn 1] S->C ReadyForQuery(T)
insert 1 返回 Ok;池里的连接 pid 1022 -> 1022
  服务端 state = Some("idle in transaction"),别的连接看到 0 行
再 insert 1(主键冲突)= error returned from database: duplicate key value violates unique constraint "items_pkey" at line 666
然后 select 1 = error returned from database: current transaction is aborted, commands ignored until end of transaction block at line 1750
pool.close() 之后别的连接看到 0 行
  • 超时之后连接被归还:Sync 是 return_to_pool 的 ping,它收到 ReadyForQuery(T) 也照样放行。
  • 下一个借到它的人执行的是普通的自动提交 INSERT,实际落在那个没人管的事务里:返回 Ok,别的连接看不到。
  • 第一个出错的语句把这个事务打成 aborted,之后这条连接上每一条语句都报 25P02,直到连接被关掉。连接被关掉时服务端回滚,那条「成功」的 insert 1 也没了。

出处

PgTransactionManager::begin 在服务端确认之后才把深度加一:

        let rollback = Rollback::new(conn);
        rollback.conn.queue_simple_query(statement.as_str())?;
        rollback.conn.wait_until_ready().await?;
        if !rollback.conn.in_transaction() {
            return Err(Error::BeginFailed);
        }
        rollback.conn.inner.transaction_depth += 1;
        rollback.defuse();

取消发生在 wait_until_ready().await 里。这时 BEGIN 已经写出去了,深度还是 0。两层守卫,Rollback 和 Transaction::drop,最后都调 start_rollback,它在深度为 0 时什么也不做。客户端以为没有事务(is_in_transaction() 是 false),服务端在事务里。

然后是归还。return_to_pool 用 ping 检查连接,Postgres 的 ping 只是 write_sync 加 wait_until_ready,读到 ReadyForQuery 就算成功,不看它的状态字节。ReadyForQuery 里的状态存进了连接的 transaction_status,可读它的 in_transaction() 是 pub(crate),用户代码碰不到;公开的 is_in_transaction() 读的是深度,恰好是错的那个值。

test_before_acquire 帮不上忙:它在 check_idle_conn 里调的是同一个 ping。上面那次运行它就是开着的(默认值)。

📝NOTE
冷知识:Rollback 守卫当初就是为取消加的

0.7.3 之前,Postgres 的 begin 是 conn.execute(&*begin_ansi_transaction_sql(...)).await? 再 transaction_depth += 1,中途取消没有任何回滚。#2819(「postgres begin cancel safe」,首次发布于 0.7.3)改成今天的样子,说明里写了两点:「Using execute to perform a begin is overkill, so I have switched to using the simple query protocol like sqlx uses in drop-rollback」,以及「Implement a short lived drop-rollback struct to start the rollback incase of error/panic/cancel during wait_until_ready」,并认为「any drops will definitely rollback to that point and not to a parent BEGIN」。可守卫调的 start_rollback 按深度工作,而深度要等回应之后才加:深度 0 时它什么也不排(本页),深度大于 0 时它回滚的恰恰是外层(见 begin-with-in-transaction 的 C 段)。

上游:#4393(「Postgres: cancelling Pool::begin leaves the connection in a transaction and poisons it for the pool」,报告者在 0.8.6 和 0.9.0 上都复现了)。修复 #4394 在 2026-09-09 合并,晚于 0.9.0,做法是在 BEGIN 来回之前先把深度加一、BEGIN 没生效再减回去,让守卫有东西可回滚;写这一页时还没有带它的发布版本。begin_with 的同类报告是 #4423,让归还时检查事务状态的 #4409 还开着。

修法

先 acquire,超时只套在 begin 上,超时就 close

超时要套在 begin 上的话,先自己拿到 PoolConnection。这样被丢掉的只是 begin 的 future,连接还在你手里:

let mut conn = pool.acquire().await?;
let r = tokio::time::timeout(Duration::from_millis(100), conn.begin()).await;
let timed_out = r.is_err();
drop(r);
if timed_out {
  conn.close().await?;
}
--- 先 acquire,超时只套在 begin 上,超时就 close ---
  [conn 6] C->S Query("BEGIN")
timeout(100ms, conn.begin()) 超时 = true
  [conn 6] C->S Terminate
  [conn 6] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
insert 1 返回 Ok;池里的连接 pid 1027 -> 1028
  别的连接看到 1 行

close 发 Terminate,服务端结束会话、回滚那个事务;池补一条新连接,后面的写入正常落盘。直接给 pool.begin() 套超时就做不到这一点:连接在被丢掉的 future 里,调用方碰不到它。acquire 本身的等待由 acquire_timeout 管,不需要再套一层。

after_release 里自己查一次

想保护所有调用点,可以在池上挂一个 after_release,归还时用一条查询问服务端「这条连接是不是还在事务里」,是就返回 false 让池关掉它:

const PROBE: &str = "select transaction_timestamp() = statement_timestamp()";

let pool = PgPoolOptions::new()
  .max_connections(1)
  .after_release(|conn, _meta| {
    Box::pin(async move {
      let clean: bool = sqlx::raw_sql(PROBE).fetch_one(&mut *conn).await?.try_get(0)?;
      if !clean {
        println!("  after_release: 连接还在事务里,关掉");
      }
      Ok(clean)
    })
  })
  .connect_with(opts.clone())
  .await?;
--- after_release 探测 ---
(慢之前)select 1 = Ok
  [conn 2] C->S Sync
  [conn 2] S->C ReadyForQuery(I)
  [conn 2] C->S Query("BEGIN")
timeout(450ms, pool.begin()) = Err(Elapsed(()))
  [conn 2] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
  [conn 2] C->S Query("select transaction_timestamp() = statement_timestamp()")
  [conn 2] S->C RowDescription, DataRow(列长 1), CommandComplete("SELECT 1"), ReadyForQuery(T)
  after_release: 连接还在事务里,关掉
  [conn 2] C->S Terminate
insert 1 返回 Ok;池里的连接 pid 1023 -> 1024
  服务端 state = Some("idle"),别的连接看到 1 行
再 insert 1(主键冲突)= error returned from database: duplicate key value violates unique constraint "items_pkey" at line 666
然后 select 1 = Ok(1)
pool.close() 之后别的连接看到 1 行

探测的原理:不在显式事务里时,每条语句自成一个事务,transaction_timestamp() 和 statement_timestamp() 相等;在一个更早开始的事务里,前者是 BEGIN 的时间,两者不等;事务已经 aborted 时这条查询直接报错,after_release 返回 Err,池同样关掉连接(close_hard)。正常归还的连接((慢之前) 那几次查询)都通过了探测,没有被误关。

两个细节都是试出来的:

  • 探测必须走简单查询协议(raw_sql)。最初用 sqlx::query_scalar(PROBE) 写,它走扩展查询,两个时间戳在干净的连接上也不相等,每次归还都把连接关掉。
  • 0.8.6 上 raw_sql(...).fetch_one(&mut *conn) 在这个闭包里编译不过(implementation of Executor is not general enough),改成 sqlx::Executor::fetch_one(&mut *conn, PROBE)(&str 在 0.8.6 里同样走简单查询)就能用,结果和上面相同。

代价是每次归还多一个来回。

或者在 before_acquire 里查

同一个探测放进 before_acquire,借出空闲连接时检查。超时要改成 750 毫秒,因为 acquire 现在是 ping 加探测两个来回:

--- before_acquire 探测 ---
(慢之前)select 1 = Ok
  [conn 4] C->S Sync
  [conn 4] S->C ReadyForQuery(I)
  [conn 4] C->S Query("select transaction_timestamp() = statement_timestamp()")
  [conn 4] S->C RowDescription, DataRow(列长 1), CommandComplete("SELECT 1"), ReadyForQuery(I)
  [conn 4] C->S Query("BEGIN")
timeout(750ms, pool.begin()) = Err(Elapsed(()))
  [conn 4] C->S Sync
  [conn 4] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
  [conn 4] S->C ReadyForQuery(T)
  before_acquire: 连接还在事务里,换一条
insert 1 返回 Ok;池里的连接 pid 1025 -> 1026
  服务端 state = Some("idle"),别的连接看到 1 行
再 insert 1(主键冲突)= error returned from database: duplicate key value violates unique constraint "items_pkey" at line 666
然后 select 1 = Ok(1)
pool.close() 之后别的连接看到 1 行

也有效,但比 after_release 差:带着事务的连接先回到空闲队列,在下一次被借出之前一直占着事务里拿到的锁;而且每次借出都多一个来回。

怎么确认没中招

  • 找所有 timeout(..., pool.begin())、timeout(..., pool.begin_with(...)),以及 select! 里和别的分支竞争的 begin。请求级的超时(比如 tower 的 TimeoutLayer)把整个 handler 的 future 丢掉,如果 handler 正停在 begin() 上,效果一样。
  • 生产库上看 select pid, state, xact_start from pg_stat_activity where state like 'idle in transaction%':属于应用连接池、xact_start 很久以前的行就是这个坑留下的(也可能是别的原因,比如裸连接上没提交的事务)。
  • 日志里 25P02(current transaction is aborted)出现在不开事务的代码路径上。

小结

  • begin 在等 BEGIN 回应时被取消,深度还是 0,Rollback 守卫和 Transaction::drop 都不排 ROLLBACK;归还时的 ping 不看 ReadyForQuery 的状态字节,连接带着服务端事务回池。
  • 后果:之后借到它的自动提交写入全部悬空,第一个错误之后每条语句都报 25P02,连接关闭时一起回滚。0.8.6 和 0.9.0 都有;test_before_acquire 无效。
  • 上游 #4393,修复 #4394 合并于 0.9.0 之后。
  • 在 0.9.0 上:先 acquire、只给 begin 套超时、超时后 close;或者用 after_release 加一条简单查询协议的探测把这类连接关掉(每次归还多一个来回)。

💬 关联讨论

发起讨论
加载中...