Effect 里怎么用 SqlClient 做事务?

手写事务意味着 BEGIN、COMMIT、ROLLBACK,还要在每一条异常路径上记得回滚。漏掉一条,连接和锁就被一个半途而废的事务占着。

在 Effect 里,把要放进事务的操作写成一段 Effect,再交给 sql.withTransaction 包住:成功则提交,失败或中断则回滚,不用手写异常路径。 查询用 SqlClient.SqlClient 的模板字符串写,入参和返回行的编码解码用 SqlSchema.findOne 一类的函数绑定 Schema。

这样写之所以可靠,是因为 SQL 只是“跨进程调用”的又一个实例:契约用 Schema 声明,驱动放进 Layer,客户端是 service。如果你还没见过这个整体形状,先看 Effect 里调用 HTTP、数据库和 RPC,怎么写才不踩坑?。下面直接看代码和几个常被问到的点。

完整代码长什么样?

SqlClient.SqlClient 是 service,在 Effect 里 yield* 取出来。SqlSchema.findOne 把“请求怎么编码、结果怎么解码”绑在一起:入参用 Schema 编码,返回的行用 Schema 解码。

import { Effect, Schema } from "effect"
import { SqlClient, SqlSchema } from "effect/sql"

class Account extends Schema.Class<Account>("Account")({
  id: Schema.String,
  balance: Schema.Int
}) {}

export const makeAccounts = Effect.gen(function* () {
  const sql = yield* SqlClient.SqlClient

  const findById = SqlSchema.findOne({
    Request: Schema.String,
    Result: Account,
    execute: (id) => sql`SELECT id, balance FROM accounts WHERE id = ${id}`
  })

  const debit = (id: string, amount: number) =>
    sql`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${id}`

  const transfer = (from: string, to: string, amount: number) =>
    Effect.gen(function* () {
      yield* debit(from, amount)
      yield* sql`UPDATE accounts SET balance = balance + ${amount} WHERE id = ${to}`
    }).pipe(sql.withTransaction)

  return { findById, transfer } as const
})

transfer 里两条 UPDATE 在同一个事务里:任何一步失败,整段回滚。

查询失败和查不到,各是什么错误?

行经过 Account 解码,库里的脏数据会在这里变成 SchemaError,而不是悄悄流进业务代码。SQL 本身的失败是类型化的 SqlError,findOne 查不到时是 NoSuchElementError。

业务层不该认识这些底层错误。在 service 的边界把它们翻译成调用方关心的错误,比如“账户不存在”,底层的作为 cause 保留。

withTransaction 为什么可靠?

它建立在资源安全之上:回滚就是一个 finalizer,无论成功、失败还是被中断,都会走到。资源与 finalizer 的机制见 资源与作用域。

驱动同样是 Layer(SQLite、PostgreSQL 等各有各的包),换驱动时业务代码一行不用改。

事务里能顺手调别的服务吗?

别这么做。事务占着连接和锁,里面再去调另一个 HTTP,会把数据库的容量拖垮。事务里只放数据库操作,外部调用放到事务之外。

更多 SQL 与 RPC 的数据模型和事务细节见 Effect 的 SQL 与 RPC。

基于 effect 4.0.5。这里的代码沿用本系列原有的写法,这次没有重新运行;effect/sql 在该版本中标注为 unstable,小版本之间 API 可能变动,以你使用的版本为准。