系统设计

推荐阅读: https://github.com/donnemartin/system-design-primer

当远程服务出现故障时, 标准的处理方案通常是重试.
但如果远程服务长期无响应, 基于重试的方案会显得浪费:

  • 重试不必要地增加了网络压力
  • 故障服务以外的其他操作可能需要回滚,
    由于与故障服务相关的操作注定会失败, 那么还不如一开始就不做任何事情, 从而省去回滚步骤.

断路器模式就是基于以上现象出现的模式,
它主动维护一个远程服务的状态, 如果远程服务故障, 则将其标记为故障.
业务逻辑可以在操作开始时读取这些状态值, 如果发现所需的服务里存在故障, 就直接失败, 直到远程服务恢复.

发件箱模式是建立在队列上的"至少一次"模式, 适用于具有幂等性的操作.
它建立一个专门用于积压"下一步操作"的发件箱, 再由独立的进程从发件箱里取出"下一步操作"来实际执行动作.

当业务流程跨越多个服务时, 通常需要放弃强一致性, 选择最终一致性.
大多数基于微服务的现代应用程序都不使用分布式事务.

两阶段提交通过一个事务管理器(transation manager)对多个数据库事务进行管理.

在两阶段提交中, 每个参与者在各自的事务提交之前会投票给事务管理器,
当所有参与者投肯定票后, 事务管理器会告知每一个参与者完成事务提交.
当一个参与者投否定票后, 事务管理器会告知所有参与者回滚事务.

2PC被认为是一种有缺陷的方案.

2PC假设了以下可用性前提, 在前提不成立时有可能出现死锁:

  1. 1.
    假设投票过程(网络通信)不会出现问题
  2. 2.
    假设事务管理器不会出现问题

2PC在很大程度上只适用于依赖数据库事务的服务,
其他类型的服务的操作可能是原子的(例如大多数NoSQL).

2PC不适用于微服务, 因为微服务不能将它背后的数据库暴露给事务管理器.

一种放弃了事务整体的隔离性, 把事务拆分成子事务,
让参与子事务的微服务按要求协作, 从而最终完成大事务的模式.

事务开始时, 第一个服务完成事务, 然后通知下一个服务, 以此类推直到事务完成.
这种协作有多种实现方式, 可由事件总线发出事件来实现, 也可由服务执行RPC来实现,
不同的实现方式直接导致了不同的协作模式.

Saga模式有一个实施前提, 即"实际要完成的整个事务"必须能够被拆分为数个可逆转的子事务,
这要求架构中的微服务具有足够的隔离性.

当事务失败时, 需要以事务的相反顺序通知各个服务进行回滚, 每个服务都需要手动实现自己的回滚操作.
回滚操作通常需要事件溯源/事件日志(记录状态变更事件).
有时, 回滚操作过于复杂, 这时可能会以创建新的补偿事务的方式来替代回滚.

由于网络服务的复杂性, 有时会出现瞬态错误, 这些错误只需要重试就可以修复.
实现回滚和补偿事务的复杂度要远高于重试, 如果错误可以在短时间内通过重试解决, 就不应该让事务失败.

用于达到与已失败的事务相反方向的结果的事务.
回滚指的是撤销修改, 而补偿事务是以新的修改来覆盖掉旧修改, 二者的目标是一致的.

缺乏ACID中的隔离性, 外部访问者可以访问到事务中途的状态, 从而可能同时执行多个相同的事务或获得错误的状态.

语义锁指的是应用程序给事务中被使用的每个记录设置的特殊标志, 以表示记录当前处于未提交状态.
访问到这些未提交记录的代码应该等待该标志消失后再继续执行.

语义锁可以是单独的boolean字段, 也可以是记录的一种状态(比如pending).

设置语义锁的事务如果崩溃, 则语义锁会有陷入死锁的风险.
应用程序需要一种合适的机制来清除崩溃导致的死锁.

编排指的是由一个控制器服务控制多个微服务, 先调用微服务1, 完成任务后调用微服务2, 再调用微服务3.

当架构内的微服务以Request/Reply同步交互时, 更容易使用编排模式.

控制器适合以状态机的方式实现.

-透明度高, 代码可读性强

  • 编排器是一个额外故障点.
    为了避免编排器本身故障导致的事务失败, 编排器本身需要在发件箱模式下工作.

协同指的是每个微服务都只知道自己的职责, 任务执行由事件驱动(微服务将会监听或提取事件), 从而与其他服务解耦.

当架构内的微服务以消息队列或事件总线异步交互时, 更容易使用协同模式.

由于不需要控制器服务, 不会因为控制器的单点故障导致事务进行到一半意外停止.

由于协同模式的松耦合, 事务的流程会变得不透明.

改变一个流程可能需要同时改变其上下游的服务.

本质上和分布式数据库事务属于同一个问题, 即无法直接锁住集群里的多个服务器/数据库服务器.
只不过分布式锁的目标是锁住多个业务逻辑服务器, 而分步式事务则是跨业务逻辑或锁住多个数据库.

通过Redis的SETNX或SETNXEX(带有生存时间的SETNX)可以实现分布式锁.
但Redis的分布式锁经常被错误实现, 这些实现在高负载情况下会有可能出现错误.
如何用 Redis 实现正确的分布式锁, 请参阅 Redis实战 一书.

ZooKeeper提供分布式互斥锁的API.
其原理是使用ZooKeeper的临时有序节点实现排队机制, 序号最小的节点可以得到锁,
其他节点会监听比自己小一号的节点, 等待锁被释放
(临时有序节点断开时会被ZooKeeper自动删除, 大一号的节点收到消息后检查自己是否是最小的节点).

服务天生具备强隔离性, 但服务边界并不等同于系统架构的边界.

倘若开发人员为了添加一个功能, 需要把所有的服务都升级一遍, 那么这就算不上是解耦.
添加功能理应只需要升级对应的服务, 而不是所有服务.

因此在设计服务时, 必须让服务遵循SOLID原则.

客户端和服务端的部署应当能够分离.

例如, 服务器返回数据时相关字段总是URL而不是具体的值(例如ID),
这样就可以避免在客户端里拼接URL, 于是服务器升级相关部分的时候就可以不必升级客户端,
代价只是增加数据量.

微服务架构为服务提供了更好的隔离性, 但大大增加了部署和测试的难度.
因此微服务架构需要的额外成本会比一般的SOA高得多.

不从共享数据的角度考虑服务, 而应该从提供功能来考虑.

在使用REST时, 很容易开发出向外暴露数据模型, 基于CRUD的贫血服务, 这些都不适合微服务.

暴露内部实现细节的技术不应该被采用.

系统中的每个模块都应该"宽进严出", 对自己接收的东西宽容, 对自己发送出去的东西严格.
遵守此原则可以减少微服务客户端/消费方的代码修改.

总是同时部署微服务可能导致微服务逐渐变得耦合.

总是在不同时间部署微服务可以强制开发者采取避免版本锁定的实现方式, 有助于微服务的健康发展.

当微服务有多个实例在运行时, 需要负载均衡.
由于微服务本身是被其他服务而不是真正的用户使用,
因此可以用客户端服务发现替代传统的基于代理的负载均衡(服务端负载均衡).

拿Netflix Eureka举例, 微服务将自己注册到Eureka, 使用者再从Eureka获得具体实例的IP地址和端口.
微服务会定期向Eureka发送心跳, 以使Eureka能够及时剔除宕机的服务.
使用者则定期从Eureka更新注册表.
从某种程度上说, 服务发现的中介者就是一个支持TTL的缓存数据库.
为避免中介者单点故障导致整个网络内的服务故障, 通常会使用etcd之类的分布式键值对数据库.

仅仅靠Eureka这样的"服务注册和发现"服务并不能让负载被平均分配到各个微服务上,
因此需要靠Netflix Ribbon这样的客户端负载均衡服务.
在一些策略里, Ribbon会向微服务请求统计信息, 然后将这些统计信息用于策略.

C 一致性: 服务状态保持一致(即保持最新, 因此当节点发现自己无法返回最新信息时, 会拒绝请求)
A 可用性: 服务可以完成响应(但不一定是最新的)
P 分区容错性: 节点散布在不同的网络里, 当网络故障时, 就会出现独立运行的节点/分区(节点群).
分区容错性意味着当分区出现时, 系统仍然能够运行.
在分布式系统里, 分区容错性是一定被满足的.

只有分区出现时(即网络出现故障时), 系统才需要从C(一致性)和A(可用性)里丢弃一项.
因此当网络情况正常时, 分布式系统能够同时满足C和A.

CI和CD之所以经常放在一起谈论, 是因为二者经常只是同一个管道(pipeline)"的不同阶段(stage).
一个完整的持续交付管道分为三个阶段, 每个阶段有其各自的步骤:

  • Build(构建), 获取依赖项并编译程序
  • Test(测试), 测试程序
  • Deploy(部署), 部署程序
    管道的最佳实践是采用CI/CD专有的DSL将定义写在一个文本文件里,
    该文件会作为CI/CD唯一的事实来源存入版本控制系统.

对代码仓库的部分操作(例如push, merge, pull request)自动触发相应的管道,
以确保进入master分支的代码是可以构建且可靠的(通过自动化测试保证).

CI也可以做构建和测试以外的事, 事实上它可以用来执行任何能在Linux下执行的命令.

  • 自动化测试
  • 尽可能早的发现问题, 小问题更容易解决
  • 保证master分支的代码质量

持续将代码交付到测试环境中, 再由测试人员交付到生产环境, 最终被手动部署.

  • 代码随时处于可部署状态
  • 自动化交付

将经过持续集成验证的代码自动部署到生产环境中.

  • 实现持续交付
  • 自动化测试提供了足够多的信心
  • 自动化部署

GUI应该是薄薄的一层, 仅仅用来调用应用层的代码, 越简单越好.

GUI的测试压力会因此减少很多, 因为我们不关注GUI会引起什么业务, 而只关注GUI的变化.

将多个服务的日志聚合到一起, 主流的做法是使用ELK套件(Elasticsearch, Logstash, Kibana)来完成.

固件会导致耦合性, 这里的固件不仅包括硬件, 还包括一些平台性质的软件.

比如说, 程序耦合了语言标准库以外的东西, 例如Android平台的通知等特性.
Android平台由于版本升级等原因, 可能会导致原先的代码需要修改.
如果没有对平台进行适当的解耦, 我们很可能直接使用相关的特性, 于是就需要对代码进行多处修改.
如果我们做了恰当的解耦, 使用了平台无关的库, 那么我们只需要修改对应的实现层, 且只需修改一次.

测试不应该依赖于具体的框架.

与框架耦合的代码和真实的业务逻辑应当是分离的,
对业务逻辑执行单元和集成测试, 对耦合代码执行端到端和集成测试.

如果一处代码修改会导致很多测试出错, 那么这些测试就是脆弱的测试.
GUI测试是典型的脆弱测试.

为了避免脆弱的测试影响开发的积极性, 务必将可能导致测试脆弱性的部分解耦为单独的测试.
例如, GUI不应该和业务逻辑耦合, 因为业务逻辑的测试代码往往可以单独测试, 而且不脆弱.