当谈论 Immutability 的时候,我们在谈论什么

谈起 Immutability (不可变性),相信大多数读者先想起的是编程语言中的 finalconst 之类的常量关键词或 ImmutableMap 之类的数据结构。不可否认,它们是日常开发中的实用工具,但这仅是 Immutability 的最基础应用,而在更深入的领域,比如编程范式、数据库、服务架构设计,同样无不处处体现着 Immutability 的理念。Immutability 通常意味着用直接赋值以外的方式来表达更新,本文就来谈谈这些方式提供何种特性及其如何让我们的程序设计受益。

并发控制

Immutability 最明显的优势在于并发控制。

并发控制是现代程序设计最为核心的问题之一。多线程编程在释放单机算力的同时也带来了线程安全问题,以最小成本实现多线程的并发控制,成为几乎所有高性能应用绕不开的问题。最为典型的例子便是数据库,为平衡性能和并发控制的效果(即事务隔离性),主流数据库都会提供不同的事务隔离等级供用户设置。然而,处理线程安全问题通常依赖各种锁,而锁除了会显著拖慢性能,还大大增加系统复杂度,容易出现死锁等各类疑难问题。

避免线程安全问题的一个办法便是 Immutability。线程安全问题的本质在于 Shared State(多线程公用的状态)可能被某个线程改变,而该操作对于其他线程来说是不知情的。这在面向过程/对象编程中习以为常的事情,在函数式编程观点看来却是非常危险的。函数式编程摒弃了 Shared State,而是采用不可变的对象作为函数的输入和输出,这些对象类似于局部变量仅可在函数内访问,因此函数可以被安全地传递到任意线程上执行,并且无需考虑锁同步。这样的特性令函数式编程完美适配并行计算场景,因此事实上数据密集型计算框架(比如 Spark/Flink/Pandas 等)几乎都以函数式编程为主。

当然,基于 Immutability 的并发控制并不专属于函数式编程,在面向过程/对象编程中我们依然能在系统设计中发挥 Immutability 的威力,基本的思路是: 线程安全问题来自于多线程访问变量的不确定性,那么不如将状态的访问收敛到一个线程里,再用显式的消息传递来实现状态的读写。这样我们确保了对象状态在多线程的环境下依然有良好的封装——状态不但属于某个对象,而且属于某个线程,因此不会出现任意能访问到对象的线程都能将对象的状态乱搞一通的情况。

图1. 两个线程同时访问任意对象

打个比方,一家初创公司有销售、采购、行政三个负责人,每个负责人都有权动用公司账户并分别记账,那么公司的账目一定非常混乱很容易出现对不上的情况,而若有财务负责人来专门管帐,每个部门有收入支出则通知财务处理即可,更加规范易于管理。

熟悉设计模式的读者可能会想起 Command 模式[2]或者 Actor 模式[3],熟服务架构的读者可能会想起微服务——它们都基于消息传递进行跨进程或线程的直接通信,而不是依赖对内存或者外部存储的共同访问权限间接协作。可能有读者会问,函数式编程还可以理解,基于消息传递为什么是 Immutability 设计呢?其实很简单:不同线程或进程之间唯一共享的是不可变的消息,而消息包含的命令和参数均是确定性的,这与函数式编程中的函数非常相近。

信息完整性

Immutability 另外一项重要特性是信息完整性。

简单地用新值覆盖旧值会导致信息的丢失,原本旧值是什么无从考证,导致很难维护系统的数据一致性。解决这个问题的常见办法是通过日志来记录数据的每一项变更,每次变更时新增一个版本,同时保留旧版本,相当于自带版本控制。事实上,在数据库领域等数据一致性关键的领域,不可变的日志可以说是容错机制、数据同步和审计的基石,比如 MySQL 的 binlog、MongoDB 的 oplog 和实现事务常用的 WAL(Write Ahead Log)。在日志强大能力背后,其实是 Immutability 对于信息完整性的保护。

在如今的微服务架构时代,横跨多个服务调用的事务十分常见,维护数据一致性的复杂度很大程度上由基础设施转移到了应用层,因此我们可以发现 Immutability 的理念在应用层愈发流行。事实上,近年来一些热门概念,比如 Event Sourcing、CRQS、SAGA,都是建立在 Immutability 的基础之上。这些新架构使用异步的通信机制,通常倡导除了在常规地更新服务状态的同时,将状态变化以事件日志的形式保存下来,这些事件日志可用于排查问题、重建整个某个时间点的服务状态或者作为通知(Notification)同步给一起协作的其他服务。

考虑一个电商交易场景的微服务架构,有订单(Order)、支付(Payment)和库存(Inventory)三个独立的服务。新建一个订单,需要先后调用支付服务和库存服务,用 SAGA 实现的流程如下图所示:

图2. SAGA 工作流程

其中蓝色的为常规事务,黄色为用于回滚规范事务的补偿事务(Compensating Transaction),常规事务跟补偿事务是一对一的关系。如果某个常规事务出错,那么系统会逆序执行该事务及其前置事务的补偿事务。每个事务的执行结果都会以事件消息的形式通知 SAGA 的中心化节点 Coordinator 并被作为日志记录下来(见下图的 Saga Log)。

图3. SAGA 分布式事务日志

通过不可变的事务日志,SAGA 将多个独立的本地事务串联起来,成为一个松耦合的分布式事务。

性能

Immutability 引起最多的担忧在于它对性能(时间复杂度和空间复杂度)的影响,毕竟每次更新都产生一个完整的拷贝。诚然,若不考虑并发控制,Immutability 大多数情况下的确不如原址更新性能好(尽管可以通过 COW (Copy-On-Write) 数据结构来降低性能损耗),然而在 on-disk 场景下 Immutability 可能却是更有效率的方式。

on-disk 场景下 IOPS 非常珍贵,比起占用的空间,大家更在乎一次读或写会导致多少次磁盘 IO,即数据库领域常见的空间放大、读放大和写放大三个指标。Immutability 虽然在空间放大和读放大上不占优,但在写放大的表现上却非常优秀,其中最为典型的例子便是 LSM-tree。

简单来说,对于一次写操作(比如更新一条 1 KB 大小的记录),传统的 B-Tree 需要通过索引查找到目标记录对应的页(Page),然后原址覆盖掉整一页(通常 4K 大小),而 LSM-tree 以追加写的方式代替原址更新,只需要在最新的文件写入记录的最新值。虽然有人会说提及 LSM-tree 后续合并文件的 Compaction 同样会带来写放大,但上述过程也没有算上 B-Tree 需要的 WAL。

此外,因为 LSM-Tree 一直是追加写的缘故,IO 一直也是大块数据的顺序写,这对于传统 HDD 来说尤为重要。即使是已经采用 SSD,大块的连续写入也有助于减少磁盘空间碎片和提高压缩性能。

总结

变量与赋值作为计算机程序中最为基础的概念,绝大多数程序员已经习惯以它们为基础的思维方式,但 Immutability 并不小众,反而凭借着在并发控制、信息完整性和性能上的优势在不少新领域大放异彩。其中部分原因来自于,相比起变赋值是计算机科学为提高资源复用率发明的操作,Immutability 更贴近纯粹的数学,因此更加不容易出现竞争条件、信息丢失这些计算机科学独有的问题。

参考

  1. Wikipeida: Thread Safety
  2. Wikipeida: Command Pattern
  3. Saga Pattern in Microservices
  4. Designing Data-Intensive Applications
  5. Akka Document
  6. 计算机程序的构造和解释