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 得到的,没有逐个函数核对。