惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX
C
Check Point Blog
博客园_首页
B
Blog
D
Docker
U
Unit 42
量子位
I
InfoQ
有赞技术团队
有赞技术团队
Martin Fowler
Martin Fowler
GbyAI
GbyAI
L
LangChain Blog
云风的 BLOG
云风的 BLOG
博客园 - Franky
美团技术团队
T
The Blog of Author Tim Ferriss
阮一峰的网络日志
阮一峰的网络日志
月光博客
月光博客
Vercel News
Vercel News
Recent Announcements
Recent Announcements
雷峰网
雷峰网
大猫的无限游戏
大猫的无限游戏
小众软件
小众软件
Google DeepMind News
Google DeepMind News

暗无天日

读:AI Agent 安全日志——从可见性与隐私的两难说起 - 暗无天日 读:AI Agent 生产化——一份从原型到上线的速查清单 - 暗无天日 AI写作的语言指纹——如何让文字不那么像机器 - 暗无天日 读:50 条 Claude Code 技巧——一个工程经理的六个月使用心得 读:AI 辅助开发为什么让 E2E 测试更有价值 - 暗无天日 读:在Emacs中使用Claude Code(Spacemacs适配版) - 暗无天日 Claude Code 背后的工程哲学——读 Agent Harness Engineering 读:Agent Harness Engineering——AI 智能体不只是模型,还有套件 - 暗无天日 browser-harness:让 AI 直接接管你的浏览器 - 暗无天日 读:Security-First CI/CD —— DevSecOps 自动化实践指南 TIL: 数字小键盘的小数点陷阱与行内算术求值 - 暗无天日 Conducty:给 Claude Code 加上项目记忆和并行执行能力 - 暗无天日 读 — GitHub Trending 里的 Claude Code 技能包 读 — Prompt Caching 省钱指南 TIL: Emacs 中那些跟鼠标配合的冷门快捷键 - 暗无天日 读:Anvil——把 Emacs 变成 AI 的工具服务器 读:Emacs 代码折叠终极指南 - 暗无天日 读:Clojure 搭车客指南 - 暗无天日 git推送失败后恢复仓库损坏的完整记录 - 暗无天日 多智能体系统的两个有效模式——以及对 Claude Code 用户的启示 - 暗无天日 用 Org Babel 写 Literate 博文:扩展执行 + 定制导出 proced:Emacs 内置的进程查看器 - 暗无天日 从 proced 定制中学到的 Elisp 模式 读:让 Emacs proced 在 macOS 上显示 CPU 和内存 异步编程的函数着色税 - 暗无天日 链式调用的代价:JavaScript 和 Clojure 的共同教训 - 暗无天日 hyperfine:命令行基准测试工具 - 暗无天日 管道中的变量去哪了?——子 shell 作用域陷阱 - 暗无天日 开源包装器的信任陷阱:四个危险信号 - 暗无天日 程序员愿意为 AI 写文档,却不愿为同事写 - 暗无天日
读:Immutability 不是万能药,它是一种权衡 - 暗无天日
2026-04-29 · via 暗无天日

原文:Immutability: Not a Universal Law, But a Trade-off

不可变性(immutability)是指数据一旦创建就不再修改,每次"修改"都产生新版本,这通常被认为是函数式编程的金科玉律。但原文作者 Ivan Gavlik 认为:不可变性不是一个普适法则,而是一种需要根据场景做取舍的权衡。他从代码层、数据层、API 层三个层面分析了不可变性的适用边界。

作为一个 Clojure 程序员,我想从 Clojure 的视角重新审视这篇文章的论点。Clojure 的核心数据结构(list、vector、map、set)全部默认不可变——你没法修改一个 Clojure map 里的某个键值对,只能通过 assocdissoc 生成一个新 map。这不像 Java 那样需要程序员主动选择用 final 或不可变集合,在 Clojure 里不可变性就是唯一的选择。但即便如此,Clojure 也在很多地方做了务实的妥协(比如 =atom=、=transient=)。这些妥协本身就能帮我们理解"什么时候该用不可变性,什么时候不该用"。

代码层:不可变性基本是稳赚的

在单个函数的粒度上,选择不可变性几乎总是对的。原文的论点很简单: 不可变 = 没有隐藏的副作用 = 更容易理解和测试

Clojure 天然就是这样的:

(defn add-balance [user amount]
  (update user :balance + amount))

(def user {:id 1 :balance 100})
(def updated-user (add-balance user 20))

;; user 不变,updated-user 是新的 map
updated-user
;; => {:id 1 :balance 120}
user 保持不变,updated-user 是一个全新的 map。这就是 Clojure 的默认行为,所有数据结构都是不可变的。

并发问题不是线程造成的,是共享可变状态造成的

原文用 Java 展示了一个经典的竞态条件:多个线程同时给 balance+=1 ,期望结果是 1000000,实际得到的数字远小于此且不可预测。问题出在 this.balance += amount 这一行——它不是原子操作,两个线程可以交错执行。

public class User {
    private int balance;

    public void addBalance(int amount) {
        this.balance += amount;     }

    public int getBalance() {
        return balance;
    }
}

User user = new User(); Thread[] threads = new Thread[10];
for (int t = 0; t < 10; t++) {
    threads[t] = new Thread(() -> {
        for (int i = 0; i < 100000; i++) {
            user.addBalance(1);
        }
    });
    threads[t].start();
}
for (Thread t : threads) t.join();
System.out.println("Final balance: " + user.getBalance());

Java 的解决这一问题的方法有好几种: synchronized (加锁,有效但代价高)、 AtomicInteger (Java 提供的一种整数类型,内部用 CPU 的 CAS 指令保证 +=1 这类操作不会被线程交错打断,但值本身仍然是可变的)、完全不可变对象(每次返回新实例)。原文最终推荐的是"不可变 + 协调"的组合方案。

Clojure 在这个问题上走得更远——它用 atom 提供了一种既不可变又高效的方案:

(def balance (atom 0))

;; 2 个线程各执行 1000 次 +1,用 swap! 保证原子更新
;; swap! 内部用 CAS(Compare-And-Swap),不阻塞线程
(dotimes [_ 2]
  (future
    (dotimes [_ 1000]
      (swap! balance inc))))

;; 等所有 future 完成
(Thread/sleep 100)
@balance
;; => 2000
2000

关键点: swap! 接受一个纯函数 inc ,atom 内部保证将这个函数应用到当前值的过程是原子的。你写的是纯函数式变风格的代码,Clojure 的运行时会帮你处理协调问题。这正是原文说的"不可变状态迫使你显式地表达变化如何发生"的意思:在 Clojure 里,你必须用 swap!reset! 这样的操作来表达"我要改变状态",而不是随手写一个 + 然后祈祷不会出事。

Clojure 还有 ref (配合 dosync 实现事务性更新)和 agent (异步更新),分别对应不同的协调需求。但核心思路都一样:值是不可变的,状态的变化必须明确表达出来。

代码层结论

在代码层面,不可变性基本没有争议——代价小,收益大。原文也认同这一点。真正的权衡从数据建模开始。

数据层:从"改一个字段"到"记一笔历史"

到了数据建模层面,问题从"要不要修改这个对象"变成了更根本的设计选择:你的数据模型是记录"当前状态"还是记录"发生过什么"?

状态模型 vs 事件模型

;; 状态模型:只存当前值
(def user-state (atom {:name "Alice" :balance 100}))
(swap! user-state update :balance + 20)
(swap! user-state update :balance - 10)
;; 余额是 110,但你不知道中间发生了什么
@user-state
;; => {:name "Alice" :balance 110}
{:name "Alice" :balance 110}
;; 事件模型:存每一笔变更
(def events
  [{:type :balance-increased :amount 20 :at "2026-04-29T10:00:00Z"}
   {:type :balance-decreased :amount 10 :at "2026-04-29T10:05:00Z"}])

;; 当前状态是推导出来的
(reduce (fn [balance event]
          (case (:type event)
            :balance-increased (+ balance (:amount event))
            :balance-decreased (- balance (:amount event))
            balance))
        100 events)
;; => 110
110

两种模型都能得到余额 110,但事件模型额外保留了"怎么到的 110"这个信息。这就是原文说的核心区别:状态模型回答"现在是什么",事件模型回答"怎么到的这里"。

事件模型的代价

但事件模型不是免费的:

  1. 存储成本 :每次变更都存一条记录,数据量远大于只存当前状态。一个用户改了 100 次名字,状态模型只有 1 条记录,事件模型有 100 条。
  2. 设计复杂度 :你得设计事件的结构、状态转换的规则、不变量(invariant,即"在任何时候都必须成立的条件")。比简单的 CRUD 难得多。
  3. 不一定需要 :用户的昵称改了 100 次,你有必要知道中间 99 次发生了什么吗?

什么时候该用事件模型

原文的判断标准很实用:

  • 跨系统共享状态 时——多个服务需要知道同一份数据的变化历史
  • 历史有业务价值 的领域——金融交易、审计日志、工作流状态
  • 需要回溯查询 时——我想知道"上周三下午 3 点这个订单是什么状态?"

什么时候不该用:

  • 大对象频繁小更新(比如一个巨大的 JSON 每次改一个字段)
  • 历史没有业务价值(比如用户头像 URL 改了,你不在乎它之前是什么)
  • 简单的 CRUD 系统(表单→数据库→保存,没有复杂的业务流程)

Clojure 中的 reduce 和事件模型

Clojure 的 reduce 天然适合事件模型——"从初始状态出发,依次应用每个事件,得到最终状态"。上文的例子已经展示了这一点。更实际的做法是定义一个 apply-event 函数,把状态转换逻辑集中管理:

(defn apply-event [state event]
  (case (:type event)
    :user-registered (assoc state :status :active)
    :user-suspended  (assoc state :status :suspended)
    :user-activated  (assoc state :status :active)
    state))

(reduce apply-event
        {:status :pending}
        [{:type :user-registered}
         {:type :user-suspended}
         {:type :user-activated}])
;; => {:status :active}
{:status :active}

不可变模型的几种变体

原文还列举了几种不同的不可变数据建模方式:

模式 存什么 适用场景
事件溯源(Event Sourcing) 领域事件(发生了什么) 金融、审计、工作流
版本化状态(Versioned State) 完整状态的每次版本 需要回溯但不想重放事件
追加日志(Append-Only Log) 字段变更(什么字段从什么值变成了什么值) 轻量级变更追踪
不可变值对象(Immutable Value Objects) 不可变的领域对象,但持久化当前状态 大多数 CRUD 系统

API 层:Command 和 Event 不是一回事

原文最后讨论了 API 层面。传统的 REST API 是面向状态的,即客户端发送最终期望的状态:

PUT /orders/123
{"status": "SHIPPED"}

这告诉服务端"把这个订单的状态设为 SHIPPED",但不表达"为什么"要这么做。

Command 风格的 API 改为发送意图:

POST /orders/123/ship

这个 API 表达的是"发货"这个业务动作,而不是"把某个字段设为某个值"。服务端负责决定发货之后状态应该怎么变。

Event 风格的 API 则记录发生了什么:

POST /orders/123/events
{"type": "OrderShipped", "timestamp": "2026-04-29T10:00:00Z"}

原文特别强调了一点:Command 是请求("请发货"),Event 是事实("已经发货了")。通常客户端发 Command,服务端处理完之后生成 Event。不应该让客户端直接发 Event——因为只有服务端才有资格确认"这件事确实发生了"。

什么时候用 Command/Event 风格

  • 工作流重要、业务逻辑复杂
  • 多个系统需要对同一个动作做出反应
  • 审计追溯有要求

什么时候该避开:

  • 简单的 CRUD 应用,由于没有复杂的业务流程,状态就能直接表示动作,这时加 /ship/cancel/refund 这些端点反而是过度设计

Clojure 的 multimethod 视角

Clojure 的 defmulti / defmethod 天然适合 Command 风格的路由——根据 Command 的类型分发到不同的处理函数:

(defmulti handle-command :action)

(defmethod handle-command :ship [_ order]
  ;; 发货逻辑:更新状态、触发通知等
  (assoc order :status :shipped))

(defmethod handle-command :cancel [_ order]
  (assoc order :status :cancelled))

(handle-command {:action :ship} {:id 123 :status :pending})
;; => {:id 123, :status :shipped}
{:id 123, :status :shipped}

这种写法的好处是新增一个 Command 只需要加一个 defmethod ,不需要修改已有的处理逻辑。每个 Command 的处理逻辑是独立的,互不干扰。

三个层面的权衡总结

层面 不可变性推荐度 关键权衡
代码层 强烈推荐 代价小(多用一点内存),收益大(并发安全、容易推理、容易测试)
数据层 看场景 事件模型提供完整历史但增加存储和设计复杂度;状态模型简单但丢失历史
API 层 看场景 Command/Event 风格适合复杂业务;CRUD 风格适合简单应用

Clojure 的设计哲学很好地体现了这个递进的权衡:在代码层面全面拥抱不可变性(所有数据结构默认不可变),在数据层面提供灵活的选择(你可以用 atom 存状态,也可以用 reduce 重放事件),在 API 层面不强制(你可以用 defmulti 做 Command 风格,也可以直接用 map 存状态)。

不可变性不是一个开关,而是一个旋钮。根据场景调到合适的位置,才是务实的做法。