Chapter 04
陷阱:10 个真实失败模式
前三章建立了概念、机制、实操。这一章是反面教材——新手最常撞上的 10 个失败模式,按命中时间排序。每个都给症状、根因(回到 02 章的机制),和正反代码对照。看完你能在写错时认出"这是哪一类问题"。
本章你将建立的 schema
- 把模糊的"它不工作"翻译成具体失败模式 + 根因机制
- 四类陷阱:惰性、错误通道、依赖注入、并发——各自的典型症状
- 诚实的边界:什么时候不该用 Effect
4.0陷阱地图
10 个陷阱归四类,每一类都能追溯到 02 章的某个机制。撞上某个症状时,先定位它属于哪一类,再回到对应机制看根因——比死记 10 条快。
4.1惰性陷阱(P1–P3)
P1 · 定义了 effect,却什么都没发生
症状:写了 Effect.log("hi") 或一段抓取逻辑,运行程序,控制台一片空白,副作用没发生。
根因:effect 是惰性的值(§2.1),定义不等于执行。没有 run*,它永远不跑。
// ❌ 什么都不会打印 —— effect 只是个值
const logHi = Effect.log("hi")
// ✅ 交给 runtime 才执行
Effect.runSync(Effect.log("hi"))
如何避免:记住"effect = 值"。程序里应当只有极少数 run*(理想是最外层一个);其余全是返回 effect、由上层组合的纯函数。
P2 · 在 Effect.gen 里混用 async/await
症状:把 fetch 直接 await 进生成器,类型乱成一团,或运行行为脱离 Effect 的控制(错误不进 E、不可中断)。
根因:gen 用 yield* 组合 effect(§2.1 + 01.5);await 会即时执行并把错误变回未类型化的抛出,绕过整套机制。
// ❌ async generator + await —— 脱离 Effect 世界
Effect.gen(async function* () {
const r = await fetch(url) // 错:await 不该出现在这里
})
// ✅ 用 tryPromise 把 Promise 包成 effect,再 yield*
Effect.gen(function* () {
const r = yield* Effect.tryPromise({
try: () => fetch(url),
catch: (e) => new HttpError({ status: 0 }),
})
})
如何避免:gen 的函数永远是普通 function*,不是 async function*;遇到 Promise 一律 tryPromise 包成 effect 再 yield*。
P3 · 看到 yield* _(...) 适配器
症状:照着某篇博客写 Effect.gen(function* (_) { yield* _(eff) }),编译报错或行为怪异。
根因:那个 _ 适配器是旧语法,3.0(2024-04)起已移除(§现状速览)。
// ❌ 旧版适配器(已移除)
Effect.gen(function* (_) {
const x = yield* _(Effect.succeed(1))
})
// ✅ 直接 yield*(需 TS 5.5+)
Effect.gen(function* () {
const x = yield* Effect.succeed(1)
})
如何避免:看到 _ 适配器就知道资料早于 2024-04,整篇都要按现版本核对。
4.2错误通道陷阱(P4–P5)
P4 · throw 进了 Effect.sync,catchAll 抓不到
症状:明明写了 catchAll,程序还是以未处理错误崩溃。
根因:Effect.sync 假定不抛;真抛出的异常变成缺陷(§2.2),不在 E 通道,而 catchAll 只管 E 通道。
// ❌ JSON.parse 抛出 → 缺陷 → catchAll 漏掉
const bad = Effect.sync(() => JSON.parse(raw)).pipe(
Effect.catchAll(() => Effect.succeed(null)),
)
// ✅ 用 try,把异常转进 E 通道,再按标签恢复
const good = Effect.try({
try: () => JSON.parse(raw),
catch: () => new ParseError({ raw }),
}).pipe(Effect.catchTag("ParseError", () => Effect.succeed(null)))
如何避免:会抛的同步代码一律 Effect.try(不是 sync);只有确信不抛才用 sync。要兜底缺陷用 catchAllCause。
P5 · catchAll 一把抓,悄悄吞错
症状:线上出问题但日志干净,错误被静默成了 fallback。
根因:catchAll 把整个 E 通道 collapse 成 never(§2.2 的减法),包括你没预料到的错误——它们被一并吞掉。
// ❌ 吞掉一切,真问题被掩盖
risky.pipe(Effect.catchAll(() => Effect.succeed(fallback)))
// ✅ 只处理你认识的标签,其余继续浮现在类型里
risky.pipe(Effect.catchTag("HttpError", recover))
如何避免:优先 catchTag/catchTags 精确处理;catchAll 留给真正的最后兜底,且要记日志。
4.3依赖注入陷阱(P6–P7)
P6 · runPromise 处类型报错:Service 不能赋给 never
症状:Type 'HttpClient' is not assignable to type 'never',新手以为是 bug。
根因:这是特性(§2.3)。R 通道还挂着没提供的服务,编译器拒绝运行一个依赖未满足的 effect。
// ❌ R 还挂着 HttpClient
Effect.runPromise(fetchWeather("x"))
// ✅ 先 provide,把 R 消成 never
Effect.runPromise(
fetchWeather("x").pipe(Effect.provide(HttpClientLive)),
)
如何避免:把报错读成"你还差一个依赖没接"。照着 R 通道列出的服务,逐个 provide 对应 Layer。
P7 · 同一个 layer 提供两次,得到两个实例
症状:"单例"服务却有两份状态(两个连接池、两份缓存)。
根因:Layer 按引用相等记忆化(§2.3)。把工厂 makeLayer() 调两次产生两个不同引用 = 两次构建。
// ❌ 每次调用工厂都是新引用 → 两个连接池
const makeDb = () => Layer.effect(Db, openPool)
a.pipe(Effect.provide(makeDb())) // 实例 1
b.pipe(Effect.provide(makeDb())) // 实例 2(不同引用)
// ✅ 建一次、共享同一引用 → 记忆化为同一实例
const DbLive = Layer.effect(Db, openPool)
a.pipe(Effect.provide(DbLive))
b.pipe(Effect.provide(DbLive))
如何避免:Layer 定义成模块级 const,到处共享同一个引用;只在确实想要新实例时用 Layer.fresh。
4.4并发陷阱(P8–P10)
P8 · Effect.all 默认顺序执行,比预期慢
症状:以为像 Promise.all 那样并行,结果 5 个请求串行跑了 5 倍时间。
根因:Effect.all 默认 sequential(§2.4),这是有意的安全默认——并发度要你显式声明。
// ❌ 默认串行
Effect.all([a, b, c])
// ✅ 显式声明并发度
Effect.all([a, b, c], { concurrency: "unbounded" }) // 或具体数字,如 5
如何避免:写 Effect.all 时永远想一下并发度。"unbounded" 会一拥而上打爆下游——对数据库/外部 API 用具体数字限流。
P9 · 失败或中断时资源泄漏
症状:偶发的连接耗尽、文件句柄泄漏,通常在出错或超时路径上。
根因:手动 open/close,中间失败或 fiber 被中断(§2.5)时 close 没跑。
// ❌ useConn 失败或被中断 → closeConn 漏掉
Effect.gen(function* () {
const conn = yield* openConn
yield* useConn(conn) // 这里炸了
yield* closeConn(conn) // 不会执行 → 泄漏
})
// ✅ acquireRelease:release 注册为 finalizer,成功/失败/中断都跑
Effect.acquireRelease(openConn, (conn) => closeConn(conn)).pipe(
Effect.flatMap(useConn),
Effect.scoped,
)
如何避免:任何"获取-使用-释放"的资源都用 acquireRelease + scoped,永不手动 close。
P10 · Effect.all 默认 fail-fast,一个失败炸全批
症状:批量任务里一个失败,整批中止,其余结果全丢。
根因:Effect.all 默认 fail-fast——任一失败就短路并中断其余(§2.2 + §2.5)。想隔离失败得把每项先变成值。
// ❌ 任一失败 → 整体失败 + 中断其余
Effect.all(tasks)
// ✅ 每项先 exit 成值,失败被隔离,整批永远成功,逐项查 Exit
Effect.all(tasks.map((t) => Effect.exit(t)))
如何避免:先问"一个失败该不该影响其他"。该隔离就 Effect.exit 每项(如 03 章调度器);该全有或全无才用裸 Effect.all。
4.5何时不要用 Effect(诚实回答)
Effect 不是免费的。它的回报在复杂度上限高的系统里才显著,小场景里学习曲线不划算。
| 场景 | 建议 | 理由 |
|---|---|---|
| 一次性脚本、简单胶水 | 别用 | Promise + try/catch 足够,引入 Effect 不划算 |
| 团队无人懂、且不打算投入学习 | 别用 | 全员看不懂的代码是负债,不是资产 |
| 想逐函数渐进引入 | 慎用 | Effect 偏"全有或全无";只要 Result 用 neverthrow 更轻 |
| 复杂并发编排 / 强错误保证后端 / 长生命周期 Agent | 值得 | 类型化错误、结构化并发、可测试性的回报随复杂度放大 |
§本章 self-check
先合上教程作答,再展开对照。
- P1(什么都没发生)和 P8(比预期慢)表面无关,但都源自同一个"反直觉默认"。各是什么默认?
- P4 和 P5 都和
catch有关,但根因相反。分别是什么? - P7 的"两个实例",要改成单例,最小改动是什么?
答案(先做完再展开)
- P1:effect 惰性,定义不执行(要 run)。P8:
Effect.all默认串行(要显式 concurrency)。两者都是"Effect 的默认和 Promise 直觉相反"。 - P4:异常变成缺陷没进 E 通道,
catchAll抓不到(该用try)。P5:catchAll抓得太多,把没料到的错误也吞了(该用catchTag)。一个漏抓、一个滥抓。 - 把
makeDb()工厂调用换成一个模块级const DbLive = ...,两处都 provide 这同一个引用——记忆化即生效。
造一个会泄漏的最小例子,再修好
写一段代码:acquire 打开资源后,在 use 阶段被一个 Effect.timeout 中断。先用手动 open/close 复现"中断时不释放",再用 acquireRelease 修好,用日志证明两种写法下 release 是否运行。
提示(卡住再展开)
让 use 阶段 Effect.sleep("5 seconds"),外面套 Effect.timeout("1 second")。手动版在 sleep 处被中断,closeConn 那行根本到不了。acquireRelease 版把 close 注册成 finalizer,中断时 runtime 仍会跑它(回看 §2.5 的 interrupt-finalizer.ts)。用 Console.log 在 release 里打印来对照。