函数式语言里「错误怎么表示」是个哲学题:抛异常是「控制流的一部分」,返回值是「数据的一部分」。Clojure 给了两条路——ex-info/ex-data 的结构化异常,与 Result/Either 风格的值化错误。没有唯一正确答案,只有「对场景合适」。本文把两种模型讲透:什么时候抛异常、什么时候返回值、错误如何携带语义、如何组合(safe/either)、如何统一兜底(中间件)、如何用 spec 拦截、如何恢复重试、如何记录。目标:你的代码「错误清晰、可诊断、可恢复」。
1. 两条错误模型:抛异常 vs 返回值
1.1 异常模型(Clojure 默认)
;; 抛:中断执行流,向上传播
(throw (ex-info "余额不足" {:code :insufficient-funds :balance 10}))
;; 接:try/catch 捕获,或向上冒泡
(try
(withdraw! account 100)
(catch ExceptionInfo e
(handle-failure (ex-data e))))
1.2 值化模型(Result/Either)
;; 不抛,返回「结果值」
(defn withdraw! [account amount]
(if (<= amount (:balance account))
{:status :ok :data (update account :balance - amount)}
{:status :err :error {:code :insufficient-funds :balance (:balance account)}}))
;; 调用方显式解构处理
(let [{:keys [status data error]} (withdraw! account 100)]
(if (= status :ok) data (report error)))
1.3 对比与选型
| 维度 | 异常模型 | 值化模型 |
|---|---|---|
| 传播 | 自动冒泡 | 显式传递 |
| 忘记处理 | 可能被顶层兜底 | 可能被忽略(危险) |
| 控制流 | 中断 | 正常数据流 |
| 可组合 | 差(try/catch 嵌套丑) | 好(map/bind 组合) |
| 性能 | 异常创建有开销 | 无 |
| 适用 | 边界/意外/不可恢复 | 业务/预期/可恢复 |
心法:铁律 = 「预期错误值化、意外错误抛出」——业务可预期的失败(余额不足、找不到)用返回值,让调用方显式处理;真正的意外(DB 连不上、bug)抛异常,让系统兜底。混用时要「在边界转换」。
2. ex-info / ex-data:结构化异常
2.1 用数据携带语义
;; 异常不只消息,还有 data
(ex-info "订单创建失败"
{:code :order-failed
:order-id 42
:cause :db-timeout})
;; (ex-data e) → {:code :order-failed :order-id 42 :cause :db-timeout}
;; 带 cause(原始异常链)
(ex-info "上游服务失败"
{:code :upstream-error :svc :payment}
original-exception)
2.2 异常分层
;; 自定义异常类型:用 derive 建立层级
(derive ::business-error ::error)
(derive ::not-found ::business-error)
(derive ::insufficient-funds ::business-error)
;; 捕获时按层级判断
(catch clojure.lang.ExceptionInfo e
(when (isa? (-> e ex-data :code) ::insufficient-funds)
...))
心法:
ex-info+ex-data让异常「结构化」——消息给人看,data 给程序看(错误码、上下文、cause 链)。再加 derive 建立错误层级,catch 就能按语义分支,而不是靠字符串匹配。
3. Result 组合子:把错误变成数据流
3.1 通用 Result 协议
;; 定义一个简单的 Result 容器
;; {:status :ok :data v} / {:status :err :error e}
(defn ok [v] {:status :ok :data v})
(defn err [e] {:status :err :error e})
;; bind:ok 继续,err 短路(Monad 的 bind)
(defn >>= [result f]
(if (= :ok (:status result))
(f (:data result))
result)) ;; err 直接透传,f 不执行
3.2 错误短路链
;; 用 >>= 串起可能失败的步骤,任一失败即短路
(-> (validate input) ;; {:status :ok ...} 或 err
(>>= validate-user)
(>>= place-order)
(>>= charge))
;; 全部 :ok → 拿到最终 data
;; 任一步 :err → 直接返回那个 err,后续不跑
心法:值化错误的威力 = 可组合——
>>=让「多步可能失败的流程」变成一条短路链,错误在数据流里显式传递,调用方一眼看到所有失败分支,还能map/filter处理错误集合。
4. 错误边界:中间件统一兜底
4.1 边界在哪里转换
两条模型的转换边界:
服务边界(REST/GraphQL handler):异常 → 结构化 HTTP 错误
任务边界(消息消费/定时):异常 → 记录 + 重试/死信
进程边界(REPL/脚本):异常 → 打印堆栈
原则:错误在「边界」从异常转成「响应/日志」,在内部保持单一模型
4.2 Ring 中间件兜底(Web 边界)
(defn error-middleware [handler]
(fn [req]
(try (handler req)
(catch ExceptionInfo e
(let [{:keys [status code message]} (ex-data e)]
{:status (or status 500)
:body {:error {:code code :message message}}}))
(catch Exception e
(log/error e "unhandled") ;; 记录原始异常
{:status 500
:body {:error {:code :internal :message "internal error"}}}))))
4.3 任务边界
(defn safe-task [f]
(try
(f)
(catch Exception e
(log/error e "task failed")
(retry-with-backoff! f)))) ;; 可恢复则重试
心法:边界统一兜底是「最后的防线」——Web 边界把业务异常映射成结构化响应,任务边界记录并重试/死信。内部随便用哪种模型,边界必须「收敛成统一的对外错误」,别让异常裸奔到用户面前。
5. 用 spec 拦截:错误不落地
5.1 校验前置
;; 输入校验放在函数最前,非法输入 → 结构化错误(不往下传)
(defn place-order [order]
(let [spec-result (s/conform ::order-spec order)]
(when (= ::s/invalid spec-result)
(throw (ex-info "非法订单" {:code :invalid-input
:problems (s/explain-data ::order-spec order)})))
...))
5.2 instrument 自动拦截
;; 开发期:instrument 让被校验的函数「参数不合法就抛」
;; (见 spec 篇:s/fdef + st/instrument)
;; 生产:合法参数照常,非法参数被 spec 拦截并报结构化错误
心法:spec 把错误拦截在「入口」而非「深处」——输入不合法在函数开头就显式报错,带
explain-data说明哪里不合法。比起「深处 N 层后爆炸」,入口校验让错误可读、可定位、可预防。
6. 恢复与重试
6.1 何时重试
可重试的错误:
网络抖动、临时超时、下游 503、锁竞争
不可重试的错误:
参数非法(重试也没用)、权限不足、业务状态冲突
→ 判断标准:错误「会不会因为再试一次而消失」
6.2 指数退避
;; 简单指数退避:1s 2s 4s ... 上限 + 抖动
(defn retry [f {:keys [max-attempts max-delay-ms]}]
(loop [attempt 1]
(let [res (try {:status :ok :data (f)}
(catch Exception e {:status :err :error e}))]
(if (or (= :ok (:status res)) (>= attempt max-attempts))
res
(do (Thread/sleep (min (* 1000 (Math/pow 2 (dec attempt)))
max-delay-ms))
(recur (inc attempt)))))))
6.3 幂等是重试的前提
重试安全 = 操作幂等
下单重发会重复下单 → 需要幂等键/服务端去重
PUT 更新(整体替换)可安全重试
扣款必须幂等(扣一次就是扣一次)
→ 凡是要重试的写操作,先问「重试会不会重复」
心法:重试是「给短暂错误第二次机会」,但只对「可恢复 + 幂等」的操作做。指数退避 + 最大次数 + 抖动是标准配置;写操作没幂等就别重试,宁可报错走人工。
7. 错误可观测性:记录与追踪
7.1 日志带上下文
;; 结构化日志:错误时带上错误码与上下文
(log/error {:event :order-failed
:order-id 42
:code :db-timeout
:ex e}) ;; 堆栈保留,便于定位
7.2 错误指标
可观测的错误指标:
- 错误率(按错误码分组)
- 重试次数分布(退避是否生效)
- 超时错误趋势(下游健康度)
- 未处理异常计数(边界兜底命中数)
→ 错误要有「看得见的量」:率、分布、趋势
心法:错误处理的下半场是「可观测性」——错误码进日志、错误率进指标、追踪贯穿调用链(见 /clojure-microservices-architecture/ 观测篇)。一个「记录齐全」的错误比「静默吞掉」的错误好一万倍。
8. 实战:完整错误处理设计
;; 分层设计总结
;; ── 内部业务层:值化错误为主
(defn withdraw! [acct amount]
(cond
(nil? acct) {:status :err :error {:code :not-found}}
(> amount (:bal acct)) {:status :err :error {:code :insufficient}}
:else {:status :ok :data (update acct :bal - amount)}))
;; ── 编排层:短路链组合
(defn transfer! [from to amount]
(-> (withdraw! from amount)
(>>= #(deposit! to (:data %)))
(>>= #(log-transfer! from to amount))))
;; ── 边界层:异常/错误统一转 HTTP
(defn transfer-handler [req]
(let [result (transfer! ...)]
(match result
{:status :ok :data d} {:status 200 :body d}
{:status :err :error {:code :insufficient}} {:status 409 :body ...}
{:status :err :error {:code :not-found}} {:status 404 :body ...})))
心法:三层设计 = 内部值化(业务预期失败显式)→ 编排短路链(»= 组合)→ 边界映射(HTTP/任务)。业务逻辑纯函数、可组合、可测试;对外行为稳定、错误清晰;意外异常被边界兜底并记录——这是 Clojure 错误处理的完整闭环。
9. 陷阱清单
| 陷阱 | 现象 | 规避 |
|---|---|---|
| 全靠异常 | 业务预期失败也抛 | 预期失败值化 |
| 全靠值化 | 意外错误被忽略 | 意外抛异常 |
| 字符串匹配错误 | 脆弱难改 | ex-data + 错误码 |
| try/catch 嵌套 | 可读性崩塌 | 短路链/中间件 |
| 重试非幂等写 | 重复副作用 | 幂等键或放弃重试 |
| 吞掉异常 | 静默失败 | 边界必 log |
| 错误不结构化 | 难监控 | 错误码 + 上下文 |
10. 速查表与一句话记忆
| 问题 | 一句话答案 |
|---|---|
| 抛异常还是返回值 | 意外抛、预期返 |
| 异常结构化 | ex-info + ex-data(码/上下文/cause) |
| 错误层级 | derive 建 isa? 层级 |
| 值化组合 | »= 短路链(Monad bind) |
| 边界统一 | 中间件/任务兜底转 HTTP/日志 |
| 入口拦截 | spec 校验 + explain-data |
| 重试 | 指数退避 + 幂等前提 |
| 可观测 | 错误码进日志、错误率进指标 |
一句话记忆:Clojure 错误处理 = 「意外抛异常、预期返回值」——业务可预期失败用 Result 值化({:status :ok/:err} + >>= 短路链,显式、可组合),意外用 ex-info/ex-data(消息给人、data 给程序:错误码/上下文/cause,derive 建层级供 catch 分支)→ 边界用中间件统一兜底(异常转结构化 HTTP/任务重试)→ spec 在入口拦截非法输入 → 重试只对「可恢复 + 幂等」操作做(指数退避)→ 错误码进日志、错误率进指标**——两条模型在边界转换、各司其职,错误清晰、可诊断、可恢复。
延伸阅读
- Clojure 函数式设计模式 — Monad/Either 与组合子模式
- Clojure REST API 设计实战 — 错误模型与结构化错误响应
- Clojure spec 与测试 — spec 校验与 instrument
- Clojure 网络服务深入 — 超时重试与错误传播
- Clojure 测试与质量工程 — 错误路径测试
- 系统架构专题 — 错误处理与容错模式
- 可观测性专题 — 日志、指标与追踪
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。