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」是我的使用规则,来自对连接占用的推理,没有单独跑过。其他数据库的驱动我没有验证过。