Effect 的 withTransaction 在失败和中断时都会回滚吗?
我故意把转账写成先给收款方加钱、再从付款方扣钱。付款方余额不够时,第二步才失败,此时收款方已经被加过一次钱,没有事务的话数据就坏了。
答案是:会。sql.withTransaction 在失败时回滚,被中断时同样回滚。 后者是我最看重的性质:手写 BEGIN/COMMIT 时,超时、请求取消、进程关闭下事务是否收尾,取决于 finally 有没有写对;在这里它由 Scope 和中断机制保证。
这个行为靠的是 SqlClient 把事务当作一个包住 Effect 的作用域。下面用实际运行的例子逐条验证,再讲事务里该放什么。SqlClient 与 Effect 的 service、Layer 写法见 Effect 里怎么用 SqlClient 做事务?。
业务失败时回滚到什么状态?
transfer 把两步放进 sql.withTransaction(adjust 先读余额再更新,余额不足时以 InsufficientFunds 失败,完整的 Accounts service 和模型定义见 Effect 的 Model.Class 怎么用一份声明派生所有变体?):
const transfer = (from: AccountId, to: AccountId, amount: number) =>
sql.withTransaction(
Effect.gen(function*() {
yield* adjust(to, amount)
yield* adjust(from, -amount)
})
).pipe(Effect.catchTag("SqlError", Effect.die))运行下面的脚本:
// file: sql-demo.ts
import { Effect, Layer } from "effect"
import { SqlClient } from "effect/sql"
import { Accounts, SqlLive } from "./sql.js"
const Live = Accounts.layerNoDeps.pipe(Layer.provideMerge(SqlLive))
Effect.gen(function*() {
const accounts = yield* Accounts
const sql = yield* SqlClient.SqlClient
const a = yield* accounts.open("alice", 100)
const b = yield* accounts.open("bob", 10)
const failure = yield* accounts.transfer(a.id, b.id, 500).pipe(Effect.flip)
console.log(failure._tag, (yield* accounts.get(a.id)).balance, (yield* accounts.get(b.id)).balance)
yield* accounts.transfer(a.id, b.id, 30)
console.log((yield* accounts.get(a.id)).balance, (yield* accounts.get(b.id)).balance)
yield* sql.withTransaction(
Effect.gen(function*() {
yield* sql`UPDATE accounts SET balance = 0`
return yield* Effect.never
})
).pipe(Effect.timeoutOption("50 millis"))
console.log((yield* accounts.get(a.id)).balance)
}).pipe(Effect.provide(Live), Effect.runPromise)第一行输出是 InsufficientFunds,两个余额仍是 100 和 10,说明已经加给收款方的钱被回滚了。成功转账后是 70 和 40。
被中断时也回滚吗?
脚本的最后一段在事务里执行 UPDATE,然后用 Effect.never 永远不返回,再用 50ms 的 Effect.timeoutOption 把它打断。打断之后读出来的余额仍是 70,UPDATE ... SET balance = 0 没有留下。
超时、请求取消、进程关闭都是中断的来源,它们走同一条路径,所以不用为每种情况单独写收尾逻辑。
事务里应该放什么?
只放数据库操作和纯逻辑,不要在事务中调用外部 HTTP。事务期间持有连接,外部调用的延迟会直接变成连接占用时间。
如果非调不可,就先调完,再把结果带进事务。
版本:驱动包 @effect/sql-sqlite-node 用的是 4.0.5。失败回滚、成功转账的余额、50ms 超时中断后的回滚,都在内存 SQLite 上跑过。「事务里不要调用外部 HTTP」是我的使用规则,来自对连接占用的推理,没有单独跑过。其他数据库的驱动我没有验证过。