Effect.tx 事务体里有哪些坑?

我在一个 Effect.tx 事务体里放了一个计数器,事务只被阻塞唤醒了一次,计数器的值却是 2。

坑有三个:事务体会被重跑,里面的副作用也会重复;内层 Effect.tx 不是保存点,失败被捕获后写入照样提交;Tx 家族有一部分模块是 unstable。 前两个是行为,后一个是稳定性标注。

这些都和 v4 里取代 STM 的事务模型有关:每次读写记录在日志里,最外层成功时一次提交,背景见 Effect v4 没有 STM 了,怎么原子地改多个状态?。下面逐个看。

为什么事务体里的副作用会重复?

事务体会因为 txRetry 或提交时的冲突重新执行,所以在事务体里发请求、写日志、改普通变量都是错的。准则是:事务体里只放对 Tx 结构的读写和纯计算,副作用放到事务提交之后。

下面的代码里,事务体用 Effect.sync 给计数器加一,被 txRetry 阻塞后由 gate 变化唤醒:

import { Effect, Fiber, TxRef } from "effect"

const program = Effect.gen(function*() {
  // 观察一个事务内重跑了几次:副作用会随重试重复
  const gate = yield* TxRef.make(false)
  let bodyRuns = 0
  const waiter = yield* Effect.forkChild(
    Effect.tx(Effect.gen(function*() {
      yield* Effect.sync(() => {
        bodyRuns++
      })
      if (!(yield* TxRef.get(gate))) return yield* Effect.txRetry
    }))
  )
  yield* Effect.sleep("10 millis")
  yield* TxRef.set(gate, true)
  yield* Fiber.join(waiter)
  console.log("事务体执行次数", bodyRuns)
})

Effect.runPromise(program)

输出是 事务体执行次数 2。

怎么知道事务为什么老在重跑?在事务内 yield* Effect.Transaction,可以拿到 attempt 和 retryReason("retry" 或 "conflict"),适合排查。

内层事务失败,会只回滚内层吗?

不会。内层 Effect.tx 失败、被外层捕获后,内层的写入仍然留在同一个日志里,最后随外层一起提交:

import { Effect, TxRef } from "effect"

const program = Effect.gen(function*() {
  // 内层事务失败被外层捕获:内层的写入并不会单独回滚
  const outer = yield* TxRef.make("untouched")
  const inner = yield* TxRef.make("untouched")
  yield* Effect.tx(Effect.gen(function*() {
    yield* TxRef.set(outer, "written")
    yield* Effect.tx(Effect.gen(function*() {
      yield* TxRef.set(inner, "written")
      return yield* Effect.fail("inner failed")
    })).pipe(Effect.ignore)
  }))
  console.log(yield* TxRef.get(outer), yield* TxRef.get(inner))
})

Effect.runPromise(program)

输出是 written written。如果你习惯数据库的 savepoint 语义,期待内层失败只回滚内层,在这里是不成立的。

只有错误穿透到最外层,才会整体回滚。我专门验证过后一点:事务体先写 TxRef 再失败,失败后值保持原样。想要「尝试一段,失败就当没发生」的效果,就让它整体失败,或者在写入之前先做检查。

哪些 Tx 模块不稳定?

看 JSDoc 的 @stability 标注:Effect.tx、Effect.txRetry、TxRef、TxHashMap、TxSemaphore 等是 stable,但 TxChunk 整个模块是 unstable,TxQueue 的大多数导出是 unstable,TxPubSub 和 TxSubscriptionRef 有部分导出是 unstable。

依赖它们之前,先查一下你要用的那个函数的标注,unstable 的要做好升级时改写的准备。

事务体里能不能当互斥区用?

不能。事务体可能重跑,副作用会重复,所以它保证的是状态变更原子,不是临界区只执行一次。需要后者时的选择,见 多个状态要一起改,用 Ref、Semaphore 还是 Tx?。

版本是 effect@4.0.5。事务体执行次数为 2、内层写入仍被提交这两个输出,来自本地运行;「事务体先写 TxRef 再失败,值保持原样」也在本地验证过。Effect.Transaction 的 attempt 与 retryReason 来自文档,没有单独运行;各模块的 stable、unstable 标注是读 JSDoc 得到的,没有逐个函数核对。