after_save 返回了 Err,行却已经写进去了
主线位置:写入主线的两端。
am.insert(&db)是before_save→INSERT ... RETURNING→after_save三步,用的都是调用方给的db,中间没有BEGIN。钩子的参数和调用点见 save 与 ActiveModelBehavior 的「四个钩子和一个 new」「钩子里报错:什么被回滚了」,事务本身见事务。
sequenceDiagram participant App as 调用方 participant AM as ActiveModel insert participant PG as Postgres App->>AM: insert(&db) AM->>PG: INSERT note(自动提交) AM->>PG: 钩子 INSERT audit(自动提交) AM-->>App: Err,来自 after_save Note over PG: 两行都留下了
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,sea-orm 2.0.4,以及本站用的 1.1.20,同一份代码。下面的 wire> 行是夹在 sqlx 和 Postgres 之间的一个 TCP 代理打印的:客户端每执行一条语句打一行 SQL,wire< 是 ReadyForQuery 里事务状态的变化(I 空闲、T 事务中)。用代理而不是 sqlx 日志,是因为 sqlx 日志里没有 BEGIN 和排队的 ROLLBACK(见 sqlx 卷的事务没 commit)。
现象
在 after_save 里做一道检查或者写一条审计记录,失败时返回 Err。调用方拿到 Err,按「保存失败」处理,提示用户重试;用户重试后,表里出现了两条一样的记录。
最小复现
note 实体的钩子:先写一条 audit,再拒绝超过 10 个字节的标题(src/lib.rs):
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn after_save<C>(model: Model, db: &C, insert: bool) -> Result<Model, DbErr>
where
C: ConnectionTrait,
{
// write an audit row, then refuse titles that are too long
let action = if insert { "insert" } else { "update" };
db.execute_unprepared(&format!("INSERT INTO audit (note_id, action) VALUES ({}, '{action}')", model.id))
.await?;
if model.title.len() > 10 {
return Err(DbErr::Custom(format!("title too long: {}", model.title.len())));
}
Ok(model)
}
}
src/bin/after_save.rs 在连接池上直接插入(counts 数两张表的行数,它自己的查询不打印):
let r = note::ActiveModel { title: Set("a very long title".into()), ..Default::default() }.insert(&db).await;
println!("result: {r:?}");
println!("after: {}", counts(&db, &w).await?);
before: note=0 audit=0
--- 1. insert(&db), after_save returns Err
wire> INSERT INTO "note" ("title") VALUES ($1) RETURNING "id", "title"
wire> INSERT INTO audit (note_id, action) VALUES (1, 'insert')
result: Err(Custom("title too long: 17"))
after: note=1 audit=1
两条 INSERT 之前没有 BEGIN,连接一直是 I(空闲)状态,每条语句执行完就提交了。调用方拿到 Err,表里多了一条 note 和一条 audit。1.1.20 上输出逐行相同。
出处
ActiveModelTrait::insert 的全部内容:
let am = ActiveModelBehavior::before_save(self, db, true).await?;
let model = <Self::Entity as EntityTrait>::insert(am)
.exec_with_returning(db)
.await?;
Self::after_save(model, db, true).await
没有 begin,db 原样传给 INSERT 和钩子。db 的类型是 C: ConnectionTrait,可以是 &DatabaseConnection(每条语句从池里取一条连接、自动提交),也可以是 &DatabaseTransaction,insert 不区分。update 是同样的三步,save 在两者之间选一个,delete 的 after_delete 也在 DELETE 执行之后才跑。trait 的文档也写得很清楚:before_save 是「Return an error to abort the operation」,after_save 是「Hook invoked after insert, update, and save succeed」。after_save 返回的 Err 只改变了调用方看到的结果,不能撤销已经执行的语句。
修法
把调用放进事务。 钩子拿到的 db 就是这个事务,它写的东西和 INSERT 在同一个事务里;出错时事务被 drop,一起回滚:
// the fix: the insert and its hooks in one transaction
async fn create_in_txn(db: &DatabaseConnection, title: &str) -> Result<note::Model, DbErr> {
let txn = db.begin().await?;
let m = note::ActiveModel { title: Set(title.into()), ..Default::default() }.insert(&txn).await?;
txn.commit().await?;
Ok(m)
}
--- 2. fix: begin, insert(&txn), commit
wire> BEGIN
wire< ReadyForQuery(T)
wire> INSERT INTO "note" ("title") VALUES ($1) RETURNING "id", "title"
wire> INSERT INTO audit (note_id, action) VALUES (2, 'insert')
result: Err(Custom("title too long: 17"))
wire> ROLLBACK
wire< ReadyForQuery(I)
after: note=1 audit=1
wire> BEGIN
wire< ReadyForQuery(T)
wire> INSERT INTO "note" ("title") VALUES ($1) RETURNING "id", "title"
wire> INSERT INTO audit (note_id, action) VALUES (3, 'insert')
wire> COMMIT
wire< ReadyForQuery(I)
result: Ok(Model { id: 3, title: "short" })
after: note=2 audit=2
失败的那次,? 让函数在 commit 之前返回,txn 被 drop,ROLLBACK 在连接归还时发出(result: 先打印,ROLLBACK 后出现,就是这个原因),行数仍是 1 和 1。成功的那次以 COMMIT 结束。被回滚的 2 号主键不会再用:序列不随事务回滚。1.1.20 上输出逐行相同。也可以用 db.transaction(|txn| Box::pin(async move { .. })) 闭包,返回 Err 时它替你回滚,见事务的「transaction 闭包与 TransactionError」。
能在写之前判断的,放进 before_save。 它返回 Err 时语句根本不发(见 save 与 ActiveModelBehavior),不需要事务就是干净的。after_save 适合放依赖数据库生成值(主键、默认值)的后续动作,并且要在事务里调用。
怎么确认没中招
- 搜
impl ActiveModelBehavior,找出覆盖了after_save/after_delete并可能返回Err的实体,再找它们的insert/update/save/delete调用点,看传进去的是&txn还是&db。本站所有实体的ActiveModelBehavior都是空 impl,不受影响。 - 测试里让钩子失败一次,然后用另一条连接数行数;在同一个事务里数会看到自己未提交的写入。
- 开着
RUST_LOG=sqlx=info时看不到BEGIN,但能看到COMMIT:一次带钩子的写入后面没有COMMIT,就是没在事务里。
小结
ActiveModel的insert/update/save/delete不开事务,钩子和语句用的是调用方给的同一个db。- 在
&DatabaseConnection上,after_save返回Err时,INSERT和钩子自己写的东西都已经提交;调用方看到的是失败。 - 修法:在
db.begin()得到的事务里调用、成功后commit,或用transaction闭包;能提前判断的检查放进before_save。 - 2.0.4 和 1.1.20 行为相同。