Rust in Everything
基于 sea-orm 2.0.4

超时取消 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:探测是第四个来回。

📝NOTE
冷知识: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)。

💬 关联讨论

发起讨论
加载中...