事务里调 begin_with:外层事务被静默回滚
主线位置:查询主线和连接池主线的交叉点,坏在事务的深度计数上。深度记在连接上,
Transaction::begin失败时的清理按「当前深度」回滚,而这时的当前深度是外层那一级。begin/begin_with和深度的关系见事务。
flowchart TB outer["外层 begin 深度 1"] --> w1["insert 1"] w1 --> bw["begin_with"] bw --> refuse["深度大于 0 直接 Err"] refuse --> dropinner["内层 Transaction drop"] dropinner --> rb["按深度 1 排 ROLLBACK"] rb --> w2["insert 2 自动提交"] w2 --> fake["外层 commit 返回 Ok"]
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0",对照 sqlx = "=0.8.6"。
现象
一个辅助函数想用 SERIALIZABLE 开事务,调用方已经开了一个事务,把 &mut *tx 传了进去。辅助函数返回 Err(InvalidSavePointStatement),调用方记了日志、继续执行、最后 commit 成功。结果外层事务在出错之前写的数据没了,出错之后写的数据却都在,而且是在事务之外逐条提交的。
最小复现
src/bin/begin_with_nested.rs:
// a helper that wants SERIALIZABLE and does not know whether its caller already opened a transaction
async fn audit(conn: &mut PgConnection) -> Result<(), sqlx::Error> {
let mut tx = conn.begin_with("BEGIN ISOLATION LEVEL SERIALIZABLE").await?;
sqlx::query("insert into pit2.items values (100)").execute(&mut *tx).await?;
tx.commit().await
}
let mut tx = pool.begin().await?;
insert(&mut tx, 1).await?;
let r = audit(&mut tx).await;
println!("audit = {r:?}");
println!("tx.is_in_transaction() = {}", tx.is_in_transaction());
insert(&mut tx, 2).await?;
println!("commit = {:?}", tx.commit().await);
池只有一条连接,经过打印协议消息的代理(一次查询的全过程 里那个);表内容用另一条连接读:
--- A: 事务里调 audit(),出错后继续,最后 commit ---
[conn 1] C->S Sync
[conn 1] S->C ReadyForQuery(I)
[conn 1] C->S Query("BEGIN")
[conn 1] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, CommandComplete("INSERT 0 1"), CloseComplete, ReadyForQuery(T)
audit = Err(InvalidSavePointStatement)
tx.is_in_transaction() = false
[conn 1] C->S Query("ROLLBACK")
[conn 1] S->C CommandComplete("ROLLBACK"), ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, CommandComplete("INSERT 0 1"), CloseComplete, ReadyForQuery(I)
commit = Ok(())
[conn 1] C->S Sync
[conn 1] S->C ReadyForQuery(I)
表里 = [2]
begin_with 一条消息都没发就返回了错误,但之后线上多了一条 ROLLBACK,回滚的是外层事务;insert 2 的 ReadyForQuery(I) 说明它在事务之外执行;外层 commit 什么也没发。调用方如果决定回滚,也一样(B 段):
--- B: 同样出错,但调用方决定 rollback ---
audit = Err(InvalidSavePointStatement)
rollback = Ok(())
表里 = [2]
rollback() 返回 Ok,2 却留下了。
出处
两段代码叠在一起。0.9 的 Transaction::begin 先造出 open: true 的 Transaction,再调驱动(为了取消时能回滚,#3980,见事务的冷知识);PgTransactionManager::begin 在深度大于 0 时拒绝自定义语句:
let statement = match statement {
// custom `BEGIN` statements are not allowed if we're already in
// a transaction (we need to issue a `SAVEPOINT` instead)
Some(_) if depth > 0 => return Err(Error::InvalidSavePointStatement),
Some(statement) => statement,
None => begin_ansi_transaction_sql(depth),
};
驱动返回错误,Transaction::begin 里那个 open: true 的值被 drop,Drop 调 start_rollback。start_rollback 只看连接上的深度:此刻是 1,于是排进 ROLLBACK(深度 1 对应的回滚语句),深度减成 0。内层事务从没开过,被回滚的是外层。之后外层 Transaction 的 commit / rollback 看到深度 0,什么都不发,返回 Ok(())。
同一个程序换成 0.8.6:
--- A: 事务里调 audit(),出错后继续,最后 commit ---
...
audit = Err(InvalidSavePointStatement)
tx.is_in_transaction() = true
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, CommandComplete("INSERT 0 1"), CloseComplete, ReadyForQuery(T)
[conn 1] C->S Query("COMMIT")
[conn 1] S->C CommandComplete("COMMIT"), ReadyForQuery(I)
commit = Ok(())
...
表里 = [1, 2]
--- B: 同样出错,但调用方决定 rollback ---
audit = Err(InvalidSavePointStatement)
rollback = Ok(())
表里 = []
0.8.6 的 Transaction::begin 先 await 驱动、成功了才构造 Transaction,驱动返回错误时没有任何东西被 drop,外层事务原样保留。所以 begin_with 这条路是 0.9.0 新出现的。
同一个坑的另一个入口:被取消的嵌套 begin
不用 begin_with,普通的嵌套 tx.begin() 半路被取消(超时、select! 的另一支先完成)也会回滚外层。C 段只 poll 一次就丢掉:
let mut tx = pool.begin().await?;
insert(&mut tx, 1).await?;
let polled = tx.begin().now_or_never().map(|r| r.map(|_| ()));
println!("now_or_never = {polled:?}");
println!("tx.is_in_transaction() = {}", tx.is_in_transaction());
insert(&mut tx, 2).await?;
println!("rollback = {:?}", tx.rollback().await);
--- C: 嵌套 begin() 被取消(只 poll 一次)---
now_or_never = None
tx.is_in_transaction() = false
rollback = Ok(())
表里 = [2]
0.8.6 输出逐字相同。这一次是 Postgres 驱动自己的 Rollback 守卫:SAVEPOINT _sqlx_savepoint_1 已经写进缓冲,深度还没加一(要等服务端回应),守卫被 drop 时同样按深度 1 排了 ROLLBACK。深度 0 时被取消是另一种后果,见 cancelled-begin。
上游:#4445(「A failed or cancelled nested begin() rolls back the enclosing transaction」)两种入口都报了,用的正是 begin_with 被拒绝这个确定性的复现;修复 PR #4446 已关闭、没有合并,写这一页时 issue 还开着。
修法
- 不要吞掉
begin/begin_with的错误。 用?往上传,调用方的外层Transaction也跟着被 drop,整个事务回滚,数据和返回值是一致的。本页的坏结果都出在「出错之后继续用外层事务」上。 - 隔离级别在最外层定。 辅助函数要开事务时先看连接是不是已经在事务里,在的话用
begin()开保存点:
async fn audit_fixed(conn: &mut PgConnection) -> Result<(), sqlx::Error> {
let mut tx = if conn.is_in_transaction() {
conn.begin().await?
} else {
conn.begin_with("BEGIN ISOLATION LEVEL SERIALIZABLE").await?
};
sqlx::query("insert into pit2.items values (100)").execute(&mut *tx).await?;
tx.commit().await
}
--- D: audit_fixed(先看 is_in_transaction)---
audit_fixed = Ok(())
rollback = Ok(())
表里 = []
不在事务里调 audit_fixed 之后表里 = [100]
在事务里调用时它成了外层的一个保存点,外层 rollback 把 1、100、2 一起撤掉;不在事务里调用时照常用 SERIALIZABLE 开事务。代价是嵌套调用时拿不到 SERIALIZABLE,隔离级别由外层决定。
- 嵌套
begin()不要套超时或放进select!。 要给事务设超时,套在最外层,超时后让整个外层事务被 drop。
怎么确认没中招
- 搜
begin_with(:每一处调用的连接可能来自&mut *tx吗? - 任何
begin/begin_with返回错误之后还继续用外层事务的代码,在 0.9.0 上都要当成「外层事务已经没了」:这时tx.is_in_transaction()是false(上面 A、C 两段)。 - 搜
timeout(、select!里包着的.begin(),确认它们不是嵌套的。
小结
- 0.9.0 里在事务中调
begin_with返回InvalidSavePointStatement,同时Transaction::drop按当前深度排进ROLLBACK,回滚的是外层事务;之后的语句在事务外逐条提交,外层commit/rollback什么都不发、返回Ok。 - 0.8.6 没有这个问题(
Transaction在驱动成功之后才构造);这条路是 #3980 把open: true提前之后出现的。 - 嵌套
begin()被取消时,驱动的Rollback守卫同样回滚外层,0.8.6 和 0.9.0 都有。上游 #4445 未修。 - 修法:错误用
?往上传,别在出错后继续用外层事务;隔离级别在最外层定,辅助函数先看is_in_transaction();不要给嵌套的begin套超时。