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 可能变动,以你使用的版本为准。