事务没 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 有一行。
问: 我写了 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闭包。