Rust in Everything
基于 sqlx 0.9.0

事务没 commit:函数返回 Ok,写入全部消失

主线位置:查询主线和连接池主线的交叉点。事务里的每条查询照常执行、照常返回 rows_affected;Transaction 被 drop 时只把 ROLLBACK 排进写缓冲,池连接在归还时的 ping 里把它发出去。机制见事务的「Drop:只能排队」。

flowchart TB
  beginnode["pool.begin()"] --> upd["两条 UPDATE 都成功"]
  upd --> ret["函数 return Ok"]
  ret --> dropnode["tx 被 drop"]
  dropnode --> q["ROLLBACK 进写缓冲"]
  q --> ping["归还时 ping 发出"]
  ping --> gone["两条 UPDATE 都没了"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0"。

现象

一个转账函数,两条 UPDATE 都返回了 rows_affected = 1,函数返回 Ok(()),余额却没变。没有错误、没有警告。

最小复现

src/bin/forgot_commit.rs,池只有一条连接:

// forgot `tx.commit()`: returns Ok(()) and the inserts are gone
async fn transfer(pool: &PgPool, from: i64, to: i64) -> Result<(), sqlx::Error> {
  let mut tx = pool.begin().await?;
  sqlx::query("update accounts set balance = balance - 10 where id = $1").bind(from).execute(&mut *tx).await?;
  sqlx::query("update accounts set balance = balance + 10 where id = $1").bind(to).execute(&mut *tx).await?;
  Ok(())
}

transfer_fixed 和它只差最后一行:tx.commit().await。用 RUST_LOG=sqlx=debug 跑,两个账户初始各 100:

--- 忘了 commit ---
DEBUG summary="update accounts set balance …" db.statement="\n\nupdate accounts set balance = balance - 10 where id = $1\n" rows_affected=1 rows_returned=0 elapsed=2.050833ms elapsed_secs=0.002050833
DEBUG summary="update accounts set balance …" db.statement="\n\nupdate accounts set balance = balance + 10 where id = $1\n" rows_affected=1 rows_returned=0 elapsed=438.125µs elapsed_secs=0.000438125
transfer = Ok(())
DEBUG summary="select id, balance from …" db.statement="\n\nselect id, balance from accounts order by id\n" rows_affected=2 rows_returned=2 elapsed=534.541µs elapsed_secs=0.000534541
balances = [(1, 100), (2, 100)]
--- 加上 tx.commit().await ---
DEBUG summary="update accounts set balance …" db.statement="\n\nupdate accounts set balance = balance - 10 where id = $1\n" rows_affected=1 rows_returned=0 elapsed=238.709µs elapsed_secs=0.000238709
DEBUG summary="update accounts set balance …" db.statement="\n\nupdate accounts set balance = balance + 10 where id = $1\n" rows_affected=1 rows_returned=0 elapsed=181.834µs elapsed_secs=0.000181834
DEBUG summary="COMMIT" db.statement="" rows_affected=0 rows_returned=0 elapsed=431.583µs elapsed_secs=0.000431583
transfer_fixed = Ok(())
DEBUG summary="select id, balance from …" db.statement="\n\nselect id, balance from accounts order by id\n" rows_affected=2 rows_returned=2 elapsed=138.542µs elapsed_secs=0.000138542
balances = [(1, 90), (2, 110)]

两段日志唯一的区别是那一行 summary="COMMIT"。BEGIN 两段里都不出现,没提交时的 ROLLBACK 也不出现。

出处

Transaction 的 Drop 在 open 为真时调 start_rollback,Postgres 的 start_rollback 是同步函数,只把 ROLLBACK 写进缓冲、深度减一。这是文档写明的行为:「If neither are called before the transaction goes out-of-scope, rollback is called」。编译器也帮不上忙:tx 绑在一个变量上、被 &mut *tx 用过,就算类型带 #[must_use] 也不会有警告。问题只在于「忘了提交」和「出错回滚」在 Drop 看来是同一件事。

日志里为什么看不到:sqlx::query 那条日志由 PgConnection::run 里的 QueryLogger 打(见一次查询的全过程)。PgTransactionManager::begin 和 start_rollback 都用 queue_simple_query 直接往写缓冲里放 Query 消息,不经过 run,所以不打日志;commit 用的是 conn.execute(...),经过 run,所以 COMMIT 有一行。

📝NOTE
没有蠢问题

问: 我写了 tx.commit().await?,只是中间某一步 ? 提前返回了,这也算中招吗?

答: 不算,那正是 Drop 回滚要处理的情形:错误往上传,调用方拿到 Err,修改不留下,两边一致。本页的坑是调用方拿到 Ok 而修改没了。会出现这种不一致的写法还有:在提交之前 return Ok(...) 的分支(比如「没什么要改的,提前返回」之后又加了写入);把 tx 传进一个函数(按值),那个函数返回时把它 drop 了;match 吞掉了某步的错误、继续走到函数末尾却没有 commit。

修法

  • 每条成功路径的最后一步是 tx.commit().await?,函数返回 commit 的结果,像 transfer_fixed 那样。
  • 需要「返回 Ok 就提交、返回 Err 就回滚」时,用 Connection::transaction 闭包(池上先 acquire),它替你写这两步,见事务的「Connection::transaction:闭包写法」。
  • 事务按值传给别的函数时,让那个函数把 Transaction 还回来,或者只传 &mut *tx,由开事务的那一层负责提交。

裸 PgConnection(不经过池)上忘了提交还有另一个后果:ROLLBACK 要等这条连接下一次被使用才发出,在那之前服务端一直处在事务里,行锁不放。见事务里 C 段的实验。

怎么确认没中招

  • 开 RUST_LOG=sqlx=debug 跑一遍会写库的测试或接口:每个开了事务的函数,日志里最后一行写操作后面都应该跟着 summary="COMMIT"。看到写操作却没有 COMMIT,就是没提交。
  • 测试里写完之后用另一条连接(或另一次 pool 查询)读回来,而不是在同一个 tx 里读:同一个事务里读得到自己没提交的写入。
  • 代码审查时搜 begin() / begin_with(,对每一个找到对应的 commit()。

小结

  • 没提交就 drop 的 Transaction 静默回滚,不报错、不打日志,函数照样可以返回 Ok。
  • Drop 只能同步地把 ROLLBACK 排进写缓冲,池连接在归还时发出,裸连接要等下一次使用。
  • sqlx 的查询日志里 BEGIN 和排队的 ROLLBACK 都不出现(它们不经过 run),只有 COMMIT 出现;有写操作没 COMMIT 就是没提交。
  • 修法:成功路径最后 commit().await?;或者用 Connection::transaction 闭包。

💬 关联讨论

发起讨论
加载中...