异常(try/catch)的问题不是「会出错」,而是错误不在地图里——函数签名看不出它会抛什么、在哪抛、调用方要不要处理。函数式错误处理的核心是把错误变成类型的一部分:Either[E, A] 说清了「要么失败带 E,要么成功带 A」,编译器帮你盯着「每个错误分支都处理了」。本文从异常的本质讲起,到 Either/Validated 的建模,再到 Effect 系统的错误通道与跨层传播,最后给出「什么时候用异常、什么时候用 Either」的决策框架。
前置:/scala-functional-programming/(纯函数与 Option/Either)、/scala-functional-effects/(IO 与 Fiber)、/scala-domain-modeling/(ADT 与错误建模)、/scala-testing-practice/(错误路径测试)。
目录
- 1. 异常处理的问题:错误不可见
- 2. 类型化错误:Either 与左偏语义
- 3. 错误建模:用 ADT 覆盖所有错误分支
- 4. 累积错误:Validated 与依赖校验
- 5. Either 与异常互转:防御边界
- 6. Effect 系统的错误通道:raiseError 与 recover
- 7. 错误传播:领域/应用/基础设施三层
- 8. 重试、超时与错误恢复策略
- 9. 何时用异常、何时用 Either
- 10. 速查表与一句话记忆
- 延伸阅读
1. 异常处理的问题:错误不可见
异常在 Java/Scala 里人人会用,但它在「可预期错误」上很糟糕:
异常的问题:
□ 签名不可见:def foo(): A 可能抛任何异常,调用方不知道
□ 漏捕获:异常向上传播到最外层 → 崩溃或错误日志
□ 跨层纠缠:DB 异常、网络异常、业务异常全堆在一个 catch
□ 性能:异常栈的构建开销(但不该为性能不用异常)
异常适合什么:
□ 程序 bug(不该发生):NPE、索引越界 → 让它崩
□ 无法恢复的系统错误:OOM、连接池耗尽 → 让它崩
□ 可预期业务错误(用户输错、余额不足)→ 不适合异常
// 异常:错误隐藏在签名外
def withdraw(acc: Account, amt: BigDecimal): Account =
if (amt > acc.balance) throw new InsufficientFunds()
else acc.copy(balance = acc.balance - amt)
工程要点:判断标准是**「可预期 vs 不可预期」**——可预期的业务失败用类型化错误(Either),不可预期的 bug/系统错误用异常。把「余额不足」这种可预期分支写成异常,就是把业务逻辑的「地图」藏了起来。
2. 类型化错误:Either 与左偏语义
Either[E, A] 把错误放进类型签名:
type Error = String
def withdraw(acc: Account, amt: BigDecimal): Either[Error, Account] =
if (amt > acc.balance) Left("余额不足")
else Right(acc.copy(balance = acc.balance - amt))
// 调用方必须处理:编译器强制
withdraw(acc, 10) match {
case Left(e) => handle(e)
case Right(a) => proceed(a)
}
Scala 2 的坑:Either 是「右偏」还是「左偏」?
□ Scala 2.11 前:非偏(map 需要显式 right/left)
□ Scala 2.12+:右偏(Right 是成功,map/flatMap 作用于 Right)
□ Scala 3:默认右偏,更符合直觉
组合:
for (a <- right1; b <- right2) yield a + b
→ 任一步 Left 则短路(Fail-fast)
工程要点:Either 的价值是**「编译器强制处理」**——签名把错误写进类型,调用方不处理就编译不过。Scala 2.12+ / Scala 3 的右偏语义让 for-comprehension 顺畅组合,天然支持 Fail-fast 的编排。
3. 错误建模:用 ADT 覆盖所有错误分支
光用 String 当错误不够——要用 ADT(代数数据类型) 精确建模:
sealed trait AccountError
case object InsufficientFunds extends AccountError
case class AccountNotFound(id: Long) extends AccountError
case object AccountLocked extends AccountError
def withdraw(acc: Option[Account], amt: BigDecimal): Either[AccountError, Account] =
acc match {
case None => Left(AccountNotFound(0))
case Some(a) if amt > a.balance => Left(InsufficientFunds)
case Some(a) => Right(a.copy(balance = a.balance - amt))
}
ADT 建模的好处:
□ 穷尽匹配:match 时编译器检查所有错误分支是否覆盖
□ 携带数据:AccountNotFound(id) 带上下文,利于日志/重试
□ 消除魔法字符串:不再有拼错的 "insufficient_fundss"
错误分类的工程价值:
□ 可重试错误(超时/暂时性) vs 不可重试(参数错)
□ 客户端错误(4xx) vs 服务端错误(5xx)→ 映射到 HTTP 状态
工程要点:错误建模用 sealed trait——穷尽匹配让「漏掉某个错误分支」变成编译错误,携带数据让错误可日志、可重试、可映射。这是类型安全错误处理的核心收益,不是换了个容器装字符串。
4. 累积错误:Validated 与依赖校验
Either 是「Fail-fast」(第一个错误就停),校验场景往往要累积所有错误:
import cats.data.Validated
import cats.data.Validated.{Valid, Invalid}
def checkName(name: String): Validated[String, String] =
if (name.length < 3) Invalid("名字太短") else Valid(name)
def checkAge(age: Int): Validated[String, Int] =
if (age < 0) Invalid("年龄非法") else Valid(age)
// 累积:把所有错误收集到一个列表
val result: Validated[List[String], (String, Int)] =
(checkName("Jo").toValidatedNel, checkAge(-1).toValidatedNel)
.mapN((n, a) => (n, a))
// Invalid(List("名字太短", "年龄非法")) ← 两个都报出来
Validated vs Either:
□ Validated:错误累积(Applicative,mapN 并行校验)
□ Either:Fail-fast(Monad,flatMap 串行)
□ 场景:表单校验/参数校验 → Validated;业务流编排 → Either
Scala 3 原生:
□ Either 保留;Validated 来自 Cats
□ 校验场景 Scala 3 可用集合累积(如 List 组合)
工程要点:校验用 Validated(累积),流程用 Either(短路)——表单校验要让用户一次看到所有问题(累积),业务编排要让第一个致命错误停止(短路)。用错模型,体验或逻辑都会别扭。
5. Either 与异常互转:防御边界
现实世界有大量抛异常的代码(JDBC、第三方库),要建立防御边界:
// 把异常转成 Either(只在边界做,不在业务里到处 try)
def runQuoted[A](block: => A): Either[Throwable, A] =
try Right(block)
catch { case t: Throwable => Left(t) }
// 用 Either 封装 JDBC 调用
def loadUser(id: Long): Either[DBError, User] =
runQuoted { jdbcQuery(id) }.left.map(e => DBError(id, e))
边界原则:
□ 只在「外来代码的入口」(DB/网络/第三方库)做 try→Either
□ 业务代码内部一旦进入 Either 世界,就不要再 try/catch
□ 边界区分错误类型:基础设施错误 → 上层可重试/降级
反向(Either→异常):
□ 只在「程序边界」(main / 框架回调)把不可恢复错误抛出去
工程要点:防御边界把「外来世界」的错误翻译成类型化错误——DB/网络/第三方库的异常在入口转成 Either,业务层从此在类型安全的世界里处理。不要在业务深处散落 try/catch,那会让错误处理失去一致性。
6. Effect 系统的错误通道:raiseError 与 recover
Cats Effect IO / ZIO 提供真正的错误通道(比 Either 更丰富):
// Cats Effect IO
val io: IO[Account] = loadAccount(id).flatMap { acc =>
if (amt > acc.balance) IO.raiseError(InsufficientFunds)
else IO.pure(acc.copy(balance = acc.balance - amt))
}
// 恢复:recover / handleErrorWith
val safe: IO[Account] = io.handleErrorWith {
case _: InsufficientFunds => IO.pure(acc) // 返回原余额
case e => IO.raiseError(e) // 其他继续抛
}
Effect 错误通道的能力:
□ raiseError:把错误放进通道(不打断 fiber)
□ handleErrorWith/recover:按类型恢复
□ attempt:把 IO[A] 转成 IO[Either[Throwable, A]]
□ 错误类型:IO[E, A](ZIO)/ IO[A](Cats 默认 Throwable)
□ 与 Fiber 结合:超时/取消也是「错误通道」的分支
分层错误(ZIO):
ZIO[R, E, A] 中 E 可以是业务错误 ADT,不必是 Throwable
工程要点:Effect 系统把错误处理升级为**「通道级」**——错误不打断线程栈,而是沿 fiber 传播,可恢复、可组合、可超时。业务错误用 ADT 放 E,系统错误用 Throwable,两层错误各归其位。
7. 错误传播:领域/应用/基础设施三层
生产代码的错误横跨三层,要定义好每层的错误形态:
三层错误模型:
□ 基础设施层(DB/网络/外部 API):基础设施错误(可重试/可降级)
□ 应用层(用例编排):应用错误(业务规则失败)
□ 领域层(核心逻辑):领域错误(账户/订单的规则约束)
映射方向:
领域错误 → 应用层包装成应用错误 → 基础设施层转成可恢复错误
对外 → 映射为 HTTP 状态码(4xx/5xx)或 API 错误响应
示例:
AccountNotFound(领域)→ 应用层 → 404 + errorCode
DB 超时(基础设施)→ 应用层 → 重试 or 503
错误响应结构:
{ "code": "ACCOUNT_NOT_FOUND", "message": "...", "traceId": "..." }
→ 客户端可程序化处理,而不是解析人话
工程要点:跨层传播的要点是**「每层翻译一次,不层层 throw」**——领域错误在应用层包装,基础设施错误在边界转成可恢复错误。对外统一用「错误码 + 消息 + traceId」的结构化响应,客户端才能程序化处理。
8. 重试、超时与错误恢复策略
错误处理不光是「返回错误」,还要决定怎么从错误恢复:
恢复策略:
□ 重试:暂时性错误(超时/连接重置)→ 退避重试
□ 降级:可选依赖挂了 → 走缓存/默认值/空结果
□ 兜底:重试 N 次仍失败 → 返回降级结果 or 明确失败
□ 熔断:连续失败 → 暂停调用(保护下游,见微服务篇)
重试工程:
□ 退避:指数退避 + 抖动(避免重试风暴)
□ 上限:最多 3-5 次,超时则放弃
□ 幂等:重试的前提是「重试不产生副作用」(幂等设计)
Effect 侧:
IO 重试:io.retry(RetryPolicies.exponentialBackoff(1.second))
超时:io.timeout(5.seconds)
重试决策示例:
DB 超时 → 重试 3 次(指数退避 1s/2s/4s)→ 仍失败 → 走读缓存降级
网络调用 → 熔断阈值 50% 错误率 → 熔断 10s → 半开探测
工程要点:错误恢复是**「策略优先」**——先定义错误分类(可重试/不可重试、可降级/必须失败),再套重试/降级/熔断。重试必须「退避 + 上限 + 幂等」,否则重试本身会打垮下游。
9. 何时用异常、何时用 Either
最后给出决策框架——不极端,按场景选:
用异常(让程序崩):
□ 程序 bug:NPE、断言失败、非法状态(不该到达的分支)
□ 系统级错误:OOM、连接池耗尽(恢复无意义)
□ 框架边界:main/回调,统一错误日志
用类型化错误(Either/Effect 通道):
□ 业务规则失败:余额不足、校验失败、状态非法
□ 可预期错误:用户输入、外部依赖的暂时性失败
□ 需要恢复/重试/降级的错误
混合实践(成熟做法):
□ 业务核心全用 ADT 错误(编译期穷尽)
□ 外来库边界 try→Either(防御边界)
□ 真正不可恢复的 bug 才让异常崩(崩溃快 = 恢复快)
工程要点:判断标准是**「错误是否可预期、是否可恢复」**——可预期且可恢复的用类型化错误,不可预期且不可恢复的用异常。成熟代码库是「ADT 错误为主 + 防御边界 + 崩溃留给 bug」,而不是「到处 try/catch」或「全用 Either 装异常」。
10. 速查表与一句话记忆
| 问题 | 一句话答案 |
|---|---|
| 异常的问题 | 错误不在签名里,不可见不可穷尽 |
| 类型化错误 | Either[E, A],编译器强制处理 |
| 错误建模 | sealed trait ADT,穷尽匹配 |
| 累积错误 | Validated(校验)vs Either(流程) |
| 外来代码怎么接 | 防御边界 try→Either |
| Effect 错误通道 | raiseError/handleErrorWith/attempt |
| 错误怎么分层 | 领域/应用/基础设施各译一层 |
| 怎么恢复 | 重试退避 + 降级 + 熔断 |
| 何时用异常 | 不可预期的 bug,让它崩 |
一句话记忆:函数式错误处理 = ADT 建模(穷尽匹配)+ Either 做流程(Fail-fast)+ Validated 做校验(累积)+ Effect 通道(raiseError/recover)+ 三层翻译(领域/应用/基础设施)+ 重试降级熔断(恢复策略)——把错误从「运行时的事故」变成「编译期的地图」。
延伸阅读
- /scala-functional-programming/ — Option/Either 基础
- /scala-functional-effects/ — IO 错误通道与 Fiber
- /scala-domain-modeling/ — ADT 与领域错误建模
- /scala-testing-practice/ — 错误路径测试
- /scala-web-http-apps/ — 错误到 HTTP 状态映射
- /scala-microservices-practice/ — 重试、熔断与降级
- Java 企业级专题 — 异常与防御式编程
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。