超时取消 db.begin():连接带着事务回到池里
主线位置:写入主线开事务的那一步。
db.begin()先从池里acquire一条连接,再调 sqlx 的TransactionManager::begin发BEGIN(事务的「begin:先 acquire,再 BEGIN,再 SET TRANSACTION」)。future 在等BEGIN回应时被丢掉,连接走 sqlx 的归还流程回到池里。机制全在 sqlx 里,见 sqlx 卷的超时取消 pool.begin();本页只讲 sea-orm 上看到什么、能怎么绕。
sequenceDiagram participant App as 应用 participant Pool as sqlx 池 participant PG as Postgres App->>Pool: timeout 包着 db.begin() Pool->>PG: Query BEGIN App--xPool: 超时 future 被丢掉 Note over Pool: 深度仍是 0 不排 ROLLBACK Pool->>PG: 归还时 ping PG-->>Pool: ReadyForQuery T App->>Pool: insert(&db) PG-->>Pool: 写进没人管的事务
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行:sea-orm 2.0.4(sqlx 0.9.0)和 sea-orm 1.1.20(sqlx 0.8.6)。程序经过一个 TCP 代理连库,代理打印每条协议消息,并且可以把含 CommandComplete("BEGIN") 或 ("SAVEPOINT") 的回应压 300 毫秒再转发;begin 外面套 100 毫秒的 timeout,取消正好落在等回应的时候。
现象
数据库慢的那一阵,有些 tokio::time::timeout(.., db.begin()) 超时了,超时被正常处理。之后一些不在事务里的写入返回 Ok,别的连接却看不到;应用的某条连接在 pg_stat_activity 里一直是 idle in transaction,直到它被关掉,那些「成功」的写入一起消失。没有任何报错指向 begin。
最小复现
src/bin/tx_cancel.rs 的 L 段,max_connections(1):
p.slow_begin.store(true, Ordering::SeqCst);
let r = tokio::time::timeout(Duration::from_millis(100), db.begin()).await;
println!("timeout(100ms, db.begin()) = {:?}", r.map(|x| x.map(|_| ())));
// 关掉延迟,等归还完成
item::ActiveModel { n: Set(1), ..Default::default() }.insert(&db).await?;
println!("insert(&db) 返回 Ok;pid {again} -> {after};从另一条连接看 item.n: {:?}", rows().await?);
db.close().await?;
--- L: db.begin() 在 BEGIN 来回途中被超时取消 ---
正常归还两次:pid 5753 -> 5753
[conn 1] C->S Sync
[conn 1] S->C ReadyForQuery(I)
[conn 1] C->S Query("BEGIN")
timeout(100ms, db.begin()) = Err(Elapsed(()))
[conn 1] C->S Sync
[conn 1] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
[conn 1] S->C ReadyForQuery(T)
insert(&db) 返回 Ok;pid 5753 -> 5753;从另一条连接看 item.n: []
db.close() 之后 item.n: []
取消之后的 Sync 是归还时的 ping,它收到 ReadyForQuery(T) 照样放行。下一个借到这条连接的 insert 以为在自动提交,其实写进了那个没人管的事务;db.close() 发 Terminate,服务端回滚,这一行彻底没了。
sea-orm 1.1.20 上同一段输出逐行相同(pid 不同)。
出处
SqlxPostgresPoolConnection::begin 先 self.pool.acquire(),再进 DatabaseTransaction::begin,后者直接调 sqlx:
InnerConnection::Postgres(c) => {
<sqlx::Postgres as sqlx::Database>::TransactionManager::begin(c, None)
.await
.map_err(sqlx_error_to_query_err)?;
sqlx 的 PgTransactionManager::begin 收到回应之后才把深度加一,取消发生在那之前,sqlx 的守卫和 sea-orm DatabaseTransaction 的 Drop 最后都按深度 0 处理,什么也不排;归还时的 ping 又不看 ReadyForQuery 的状态字节。细节在 sqlx 卷那一页。sea-orm 2.0.4 锁 sqlx 0.9.0,1.1.20 锁 0.8.6,两者都有这个问题。
上游是 sqlx 的 #4393,修复 #4394 在 2026-09-09 合并,晚于 0.9.0,还没有带它的 sqlx 发布版本。在 SeaQL/sea-orm 里没有找到对应的 issue。
修法
别让 begin 半路被丢掉
sqlx 卷的第一种绕法是先 acquire 拿到连接、只给 begin 套超时、超时就关掉连接。sea-orm 里做不到:acquire 和 BEGIN 都在 db.begin() 内部,拿一条现成连接造 DatabaseTransaction 的 new_postgres 是 pub(crate)。所以首先是不给 db.begin() / txn.begin() 单独套 timeout,不在 select! 里拿它和别的分支赛跑;借连接的等待已经有 acquire_timeout 管。
2.0:用 map_sqlx_postgres_pool_opts 挂 after_release 探测
请求级的超时(整个 handler 的 future 被丢掉)防不胜防,可以让池在归还时自己检查。sqlx 卷的 after_release 探测在 2.0.4 里能通过 ConnectOptions::map_sqlx_postgres_pool_opts 挂上去。它在 SqlxPostgresConnector::connect 的最后执行,sea-orm 自己只用 after_connect 和 before_acquire,不占 after_release:
const PROBE: &str = "select transaction_timestamp() = statement_timestamp()";
opt.map_sqlx_postgres_pool_opts(|o| {
o.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)
})
})
});
--- P: 同上,map_sqlx_postgres_pool_opts 挂 after_release 探测 ---
正常归还两次:pid 5758 -> 5758
[conn 2] C->S Sync
[conn 2] S->C ReadyForQuery(I)
[conn 2] C->S Query("BEGIN")
timeout(100ms, db.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(&db) 返回 Ok;pid 5758 -> 5759;从另一条连接看 item.n: [1]
db.close() 之后 item.n: [1]
正常归还的连接通过了探测(前两次 pid 不变),带着事务的那条被关掉,池换了一条新连接(pid 5758 -> 5759),之后的写入正常落盘。探测为什么必须走简单查询协议(raw_sql)见 sqlx 卷。
代价比在 sqlx 里更明显:sea-orm 的 &db 每条语句都借还一次连接,所以每条语句多一个来回。Q 段打开代理日志执行一条 &db 查询:
--- Q: 带探测时,一条 &db 语句 ---
[conn 4] C->S Sync
[conn 4] S->C ReadyForQuery(I)
[conn 4] C->S Parse(sqlx_s_2, "select pg_backend_pid() as p"), Describe(S, sqlx_s_2), Sync
[conn 4] S->C ParseComplete, ParameterDescription(OID ), RowDescription, ReadyForQuery(I)
[conn 4] C->S Bind(portal unnamed, stmt sqlx_s_2, ), Execute, Close(P, unnamed), Sync
[conn 4] S->C BindComplete, DataRow(列长 4), CommandComplete("SELECT 1"), CloseComplete, 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 Sync
[conn 4] S->C ReadyForQuery(I)
借出前的 ping、语句本身、探测、归还时的 ping:探测是第四个来回。
map_sqlx_*_pool_opts 是 2.0.0-rc.38 才有的1.1.20 的 ConnectOptions 只有改连接参数的 map_sqlx_postgres_opts,池参数只能用它自己列出来的那几项。PR #2770(「Add sqlx pool options mapping for drivers」,首次发布于 2.0.0-rc.38)加了三个方法,changelog/2.0.0-rc.38.md 的说法是「Three new methods to customise the underlying sqlx::pool::PoolOptions before the connection pool is created — one per driver」。在那之前,要给池挂 after_release 只能自己建 sqlx 池,也就是下面 1.1.20 的写法。
1.1.20:自己建 sqlx 池,再包成 DatabaseConnection
1.1.20 没有 map_sqlx_postgres_pool_opts,但 SqlxPostgresConnector::from_sqlx_postgres_pool 能把一个现成的 PgPool 包成 DatabaseConnection。这条路不经过 ConnectOptions,search_path、日志、超时都要在 sqlx 这边自己设;sqlx 0.8.6 里 raw_sql(..).fetch_one 在这个闭包里编译不过,换成 Executor::fetch_one(&mut *conn, PROBE)(&str 在 0.8.6 里同样走简单查询):
let url = format!("{}&options=-c%20search_path%3D{SCHEMA}", proxied_url(port));
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.after_release(|conn, _meta| {
Box::pin(async move {
let clean: bool = sqlx::Executor::fetch_one(&mut *conn, PROBE).await?.try_get(0)?;
Ok(clean)
})
})
.connect(&url)
.await
.map_err(|e| DbErr::Custom(e.to_string()))?;
let db = SqlxPostgresConnector::from_sqlx_postgres_pool(pool);
这里的 sqlx 是 sea_orm::sqlx。1.1.20 上的输出和 P 段逐行相同:探测关掉连接,insert 落盘,item.n: [1]。
取消的是保存点:探测也救不了
M 段在外层事务里插了 5,再给 txn.begin()(SAVEPOINT)套超时,之后插 6、提交外层:
--- M: txn.begin()(SAVEPOINT)在来回途中被取消,带探测 ---
[conn 4] C->S Query("SAVEPOINT _sqlx_savepoint_1")
timeout(100ms, txn.begin()) = Err(Elapsed(()))
[conn 4] S->C CommandComplete("SAVEPOINT"), ReadyForQuery(T)
[conn 4] C->S Query("ROLLBACK")
[conn 4] S->C CommandComplete("ROLLBACK"), ReadyForQuery(I)
[conn 4] C->S Bind(portal unnamed, stmt sqlx_s_1, $1=0x00000006), Execute, Close(P, unnamed), Sync
[conn 4] S->C BindComplete, DataRow(列长 4/4), CommandComplete("INSERT 0 1"), CloseComplete, 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 Sync
[conn 4] S->C ReadyForQuery(I)
外层 commit = Ok(())
--- 最终 item.n: [6]
sqlx 的守卫在深度 1 上排进去的是 ROLLBACK,回滚的是外层事务;6 在自动提交模式下写入,外层 commit 什么也不发、返回 Ok(())。连接归还时已经是 I,探测通过。这是 sqlx 的 #4445(还开着),机制见事务的「取消 begin」一节。1.1.20 输出相同(没有探测那两行)。这种情形没有归还时能做的检查,只能不取消嵌套的 begin。
怎么确认没中招
- 搜
timeout(和select!里包着的begin()、begin_with_config(、transaction(;再看请求级超时:handler 停在begin上时被整体丢掉,效果一样。 - 线上查
select pid, state, xact_start from pg_stat_activity where state like 'idle in transaction%':属于应用池、xact_start很久以前的会话就是嫌疑。 - 挂上 P 段的探测、把
println!换成一条tracing::warn!:这条日志出现过,就说明发生过。
小结
db.begin()在等BEGIN回应时被取消,连接带着服务端事务回池,之后的&db写入返回Ok却悬空,连接关闭时回滚。2.0.4 和 1.1.20 都有,根源是 sqlx #4393,修复未发布。- sea-orm 里没法「先 acquire 再 begin」;能做的是不单独取消
begin,再加一层归还探测。 - 2.0.4 用
map_sqlx_postgres_pool_opts挂after_release;1.1.20 自己建 sqlx 池、用from_sqlx_postgres_pool包起来。代价是每条&db语句多一个来回。 - 取消嵌套的
txn.begin()会回滚外层,外层commit仍返回Ok,探测无效(sqlx #4445)。