超时取消 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。上面那次运行它就是开着的(默认值)。
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加一条简单查询协议的探测把这类连接关掉(每次归还多一个来回)。