一直以来我都没有刻意去学设计模式这一块,因为认为语言本身的设计会需要人附带出一些设计模式,特别是以 Java 为主导的 OOP 语言,而这些设计模式出现其实就是让人为了接受这样的语言构造而又不得不学的东西。我认为「设计模式」应该是语言无关的,是 design-pattern 而不是 coding-pattern。
Part 1
反应式宣言 于 2014 年发布,是移动互联网发展最迅猛的时间段。国内移动互联网的用户量,流量在疯狂增长。在这样的时代背景下,有一波牛逼的工程师为了让自己的系统能够更符合当前的状况以及今后的发展搞出了这么一套准则:
responsive 即时响应性
:必须对用户作出响应。当系统有外部调用的时候,应该有且一定有快速,一致的响应时间。这可以作为系统可用性的核心指标。获得这样的特性,本质上就是降低延迟。采用的方法有利用队列、并行化等。整体的设计需要一定程度避免出现大泥球
,这一点和后来火热的为微服务理念不谋而合。但有时候我们的系统会去集成外部的非反应式的系统,并且我们的系统也依旧需要保持这样的即时响应性。后文提到的资源管理模式,流量控制模式在这种场景下会有所帮助。
resilent 回弹性
: 必须对失败作出反应,保持高可用性。软件,硬件,编程人员都可能犯错,出现失误。在很多情况下我们会非常关注一个系统的可靠性 reliability,但是错误总是会不期而至。人们一方面需要去避免错误的发生,另一方面,在反应式宣言中,设计系统应该更加关注发生了错误如何让即时响应性快速恢复。对于软件硬件最普遍的做法是 replication,提供一个副本,由于提供了副本也就意味着可能会有分布式一致性问题,所以又有很多的大佬活跃在分布式存储领域。除了提供副本,还有一种方法就是隔离,这就好比设计大型船舶的船舱是一个个的小舱室互相隔离的,即时船触礁破坏了几个舱室,也不至于导致沉船事故。还有就是熔断,有时候可能因为一些的错误导致某个服务的 API 非常慢,大量的 socket 被无效占用以至于整个服务不可用。这个时候就需要短期内让该 API 立即作出快速失败而不至于影响整个系统。
elastic 弹性
: 必须对不同的负载情况作出反应。当系统有更高负载的时候支持自动开启更多资源,相反减少。
message-driven 消息驱动
: 必须对输入作出反应。反应式宣言中提到的是反应式系统依赖异步的消息传递。
函数式编程的本质是:洞察到程序时间上可以按照纯粹的数学函数来编写;也就是说,每次给这些函数输入相同的内容时,它们将总是返回相同的值,并且不会产生副作用。这样做的好处是代码的编写,编译,分析都可以采用纯粹的逻辑推理来保证正确性。而满足函数式通常需要做到:
- 函数是一等公民。这一点目前有大量的语言都做到了。流行的比如 JavaScript,Golang。
- 无副作用,或者叫做引用透明性。如果将一个表达式替换为其求值后的结果,程序的的执行应该不受到影响。
比如对于一个 Java 代码。
1 | final StringBuffer a = new StringBuffer("foo"); |
执行之后,b 为 “oof”, a 也为 “oof”。而对于别的语言比如 Ruby
1 | a = "foo" |
执行之后,b 为 “oof”, a 不改变。
如今并发已经有非常多的做法。最早期的做法就是 1-1 模型,即用户逻辑线程与内核调度的线程一一对应的关系比如 Java,C/C++ 。这样做通常因为需要内核做上下文切换,在并发量大的情况下性能开销会非常大。还有是 N - 1 模型,比如 lua, 这样做可以避开上下文切换带来的开销。并且由于没有实质上的并行,可以避开一切数据竞争的问题。这一点也是我认为 lua 能够在给 nginx、redis 做扩展脚本的时候流行起来的原因。还有一种就是 N - M 模型,比如 Golang 自带的 goroutine 调度器和 Clojure 中的 core.async 库实现。这样的做法当然就是对前两种的取长补短。还有一种就是通过一个线程,然后在循环中给每个要并发执行的逻辑都注册自己的方法,之后再等待回调即可。这样的做法通常叫做事件循环(event loop),在 IO 密集业务中非常适合。
在实际的编码中,方面地编写异步非阻塞的代码通常会用到 Future 和 Promise。一种理解的方式是,Future 是一个和时间解耦的值。即 “Future is a value decoupled from time”。 英文理解这个意个例子思就是,I promise you a future。在未来的某个时间点你就可以通过我 Fulfill the promise 来实现。这个 Promise 只会完成一次。用 Java 举个例子,比方说现在有个方法 retrive()
返回某个数据,这个数据存在 DB 中也可能存在缓存中,在缓存中找会比在 DB 中找更快。所以,我需要做的是先从缓存中查找这个数据,如果没有的话就从 DB 中找到。假设简化成方法 retrieveFromCache()
和 retrieveFromDB()
。传统的做法就是
1 | Object retrive(Object param) { |
如果改成并发调用这两个方法以高性能,那么就需要立即拿到先返回方法的结果。首先可以给 cache 和 db 给自注册一个 future。cacheFuture
和 dbFuture
。这两个 future 都包含各自的方法。然后只需要采用 CompletableFuture.anyOf(dbFuture, cacheFuture)
即可。如果是 Golang 可以直接用 channel 配合 select
也行。
由于经常会遇到不同的线程之间在逻辑上会有关联,比方说有时候当某个变量值发生的更改需要别的线程都知晓。在目前,通过共享变量的方式已经被部分替代为传递消息的方式,比如 CSP 编程模型,而这个模型用的最多的就是 Golang 中的 channel。一个 goroutine 只需要把值交给 channel,那么别的 goroutine 就从这个 channel 中获取值,从而达到共享数据的效果。Actor 模型和这一点相似。都是采用消息传递而非共享变量
的方式。不同的是,在 Actor 模型中,每个并发的逻辑单元被称为一个 actor,每个 actor 有自己独立的邮箱。消息发送者需要知道别的 actor 的地址从而广播出去(至于怎么广播,具体广播给谁会有一定的配置和规则)。这样做相比 CSP 有两个优点:
- 由于每个 Actor 都可能存有重要的消息,所以部分 Actor 挂掉也不至于影响整个系统
- Actor 之间不仅可以在内存中寻址还可以跨越实例通信(比如 Erlang 和 Akka),从而有更高的可扩展性。
Part 2
“事件”是建立消息传递的基石。这些事件可能是当先执行程序中函数的内容以及上下文,也可能是消息队列中的具体某些消息。有两种方式进行消息传递:事件驱动(event-based)和 消息驱动(message-based),这两种都是典型的生产者 - 消费者模型。事件驱动中,生产者把信息放到一个队列中去,消费者不断得轮询从队列中消费。消息驱动中,生产者会事先知道消费者的地址,直接把消息传递给消费者。优势在于,这样使得每个消费者可能有序且并发地消费,同时由于这种引用透明性,像 Akka 的 Actor 可以做到互相之间通过网络调用来实现通信。关于异步消息如何被保证送达,通常有三个模式:1. 至多一次(at-most-once)2. 至少一次(at-least-once)3. 确切一次(exactly-once)这三个模式被现在流行的消息队列比如 Kafka,RabiitMQ 采用。
消息驱动有个前提就是系统被拆分成了可以独立部署的组件。如何拆分一直就是个非常重要的问题。
- DRY,如果说每个组件都写了一套相同了业务逻辑肯定是有问题的。
- TDD, 千万不要认为可以无脑 test-driven-development,这里指的是设计容易测试的结构 testability-driven-design。
- 设置监督层级。类似于操作系统中的进程层级,通常用更重要的监督次重要的
- 有界一致性。有时候分布式场景下实现数据强一致非常困难,但从用户的视角去观察有时候并需要强一致,这个时候需要做的是根据事务边界对数据和行为进行分组,这个技术又会在 DDD (领域驱动设计)的文献中又详细的讨论。
Part 3
本章详细列举了各种反应式系统所需要用到的设计模式
- 简单组件模式 一个组件只做一件事,并且完整做完(和 unix 编程哲学一样嘛)
- 错误内核模式 在监督层级中,将重要的应用程序或状态功能存在根部附近,将有风险的操作放到叶子节点
- 放任崩溃模式 发生异常或者崩溃的时候,优先考虑备份上下文然后重启这个线程、组件、Actor
- 断路器模式 在失败时间长的时候,断开与用户之间的连接来保护整个服务
- 主动 - 被动复制模式 保持服务的多个副本运行在不同的位置,但是在任何时刻,只接受对于其中一个位置的修改
- 多主复制模式 在不同位置上保持服务的多个副本,每处都接受修改,并在各个副本之间传播
- 主动 - 主动复制模式 在不同地方保持多个副本,所有副本都接受修改操作
- 资源封装模式 资源以其生命周期都必须由一个组件负责
- 资源借贷模式 在不转让所有权的情况下,给予客户端对稀缺资源独占的临时访问权
- 复杂命令模式 向资源发送复合指令以避免过度使用网络
- 资源池模式 在资源所有者后面隐藏一个弹性的资源池
- 托管阻塞模式 阻塞资源需要慎重考虑并明确所有权
- 请求响应模式 消息中包含一个回信地址
- **消息自包含模式 每个消息都包含处理请求以及理解其响应需要的全部信息
- 询问模式 将产生响应的过程委托给专用的临时组件
- 转发流模式 让信息和消息尽可能地直接流向目的地
- 聚合器模式 如果需要多个服务响应来计算服务的调用结果,可以专门创建一个临时组件
- 事务序列模式 将耗时长的分布式事务切换成快速的本地事务,并通过补偿来恢复。换言之:创建一个临时组件用来专门管理分布在多个组件中的一系列动作的执行过程。
- 可靠投递模式 使用 ack 来确保消息处理掉
- 拉取模式 消费者向生产者对数据的批量大小提出要求
- 托管队列模式 管理一个显式的输入队列,并对其填充级别予以反应
- 丢弃模式 丢弃请求,比不受控制地失败更可取
- 限流模式 根据与其他服务之间的约束来约定限制自己的输出速率
- 领域对象模式 将业务领域逻辑与通信,状态管理分离
- 分片模式 给予各类独一无二并且稳定的对象属性,相应地将大量领域对象进行分组分片,从而水平扩展
- 事件溯源模式 仅通过应用事件来执行状态变更,并通过将事件存储在日志中来持久化状态变更