《微服务设计》第 11 章 规模化微服务
- 2019 年 10 月 11 日
- 笔记
第 11章 规模化微服务
11. 1 故障无处不在
- 我们知道事情可能会出错,硬盘可能会损坏,软件可能会崩溃。任何读过“分布式计算的故障”( https:// en. wikipedia. org/ wiki/ Fallacies_ of_ Distributed_ Computing)的人都会告诉你,网络也是不可靠的
- 许多组织使用流程和控制来试图阻止故障的发生,但实际上很少花费心思想想如何更加容易地在第一时间从故障中恢复过来
- 许多年前,我在谷歌园区待过一段时间,当时看到过一个拥抱故障想法的例子。在山景城一栋建筑的接待区里,放着一些机架很老的机器,好像做展览一样。我注意到两件事情。首先,这些服务器没有放在服务器机箱里,它们只是机架上安插的几个裸主板。不过,更加引起我注意的事情是,硬盘竟然是被尼龙搭扣给扣上的。我问一个谷歌员工为什么要这么做,他说:“哦,硬盘总是坏,我们不想被它们搞砸。这样做的话,只需要把它们拉出来再扔进垃圾桶,然后用尼龙搭扣扣上一个新的。”
11. 2 多少是太多
- 建议你定义一些默认的跨功能需求,然后在特定的用例中重载它们。当考虑是否以及如何扩展你的系统,以便更好地处理负载或故障时,首先请尝试理解以下需求
响应时间/延迟
- 鉴于网络的性质,你经常会遇到异常值,所以将监控的响应目标设置成一个给定的百分比是很有用的。目标还应该包括你期望软件处理的并发连接 /用户数。所以,你可能会说:“我期望这个网站,当每秒处理 200个并发连接时, 90%的响应时间在 2秒以内。”
可用性
- 你能接受服务出现故障吗?这是一个 24/ 7服务吗?当
数据持久性
- 多大比例的数据丢失是可以接受的?数据应该保存多久?很有可能每个案例都不同
11. 3 功能降级
- 构建一个弹性系统,尤其是当功能分散在多个不同的、有可能宕掉的微服务上时,重要的是能够安全地降级功能
- 我们需要做的是理解每个故障的影响,并弄清楚如何恰当地降级功能。如果购物车服务不可用,我们可能会有很多麻烦,但仍然可以显示列表清单页面。也许可以仅仅隐藏掉购物车,将其替换成一个新的图标“马上回来!”
- 对于每个使用多个微服务的面向用户的界面,或每个依赖多个下游合作者的微服务来说,你都需要问自己:“如果这个微服务宕掉会发生什么?”然后你就知道该做什么了
- 通过思考每项跨功能需求的重要性,我们对自己能做什么有了更好的定位。现在,让我们考虑从技术方面可以做的事情,以确保当故障发生时可以优雅地处理
11. 4 架构性安全措施
- 有一些模式,组合起来被称为架构性安全措施,它们可以确保如果事情真的出错了,不会引起严重的级联影响。这些都是你需要理解的非常关键的点,我强烈建议在你的系统中把它们标准化,以确保不会因为一个服务的问题导致整个系统的崩塌
- 处理系统缓慢要比处理系统快速失败困难得多。在分布式系统中,延迟是致命的
- 为了避免这种情况再次发生,我们最终修复了以下三个问题:正确地设置超时,实现舱壁隔离不同的连接池,并实现一个断路器,以便在第一时间避免给一个不健康的系统发送调用
11. 5 反脆弱的组织
- 一些公司喜欢组织游戏日,在那天系统会被关掉以模拟故障发生,然后不同团队演练如何应对这种情况。我在谷歌工作期间,在各种不同的系统中都能遇到这种活动,并且我认为经常组织这类演练对于很多公司来说都是有益的。谷歌比简单的模拟服务器故障更进一步,作为年度 DiRT( Disaster Recovery Test,灾难恢复测试, http:// queue. acm. org/ detail. cfm? id = 2371516)演习的一部分,它甚至模拟地震等大规模的自然灾害。Netflix也采用了更积极的方式,每天都在生产环境中通过编写程序引发故障
- 这些项目中最著名的是混乱猴子( Chaos Monkey),在一天的特定时段随机停掉服务器或机器。知道这可能会发生在生产环境,意味着开发人员构建系统时不得不为它做好准备。混乱猴子只是 Netflix的故障机器人猴子军队( Simian Army)的一部分。混乱大猩猩( Chaos Gorilla)用于随机关闭整个可用区( AWS中对数据中心的叫法),而延迟猴子( Latency Monkey)在系统之间注入网络延迟。Netflix已使用开源代码许可证( https:// github. com/ Netflix/ SimianArmy)开源了这些工具。对许多人来说,你的系统是否真的健壮的终极验证是,在你的生产环境上释放自己的猴子军队
- 不是每个人都需要做到像谷歌或 Netflix那样极致,但重要的是,理解分布式系统所需的思维方式上的转变
11. 5. 1 超时
- 超时是很容易被忽视的事情,但在使用下游系统时,正确地处理它是很重要的。在考虑下游系统确实已经宕掉之前,我需要等待多长时间?给所有的跨进程调用设置超时,并选择一个默认的超时时间。当超时发生后,记录到日志里看看发生了什么,并相应地调整它们
11. 5. 2 断路器
- 使用断路器时,当对下游资源的请求发生一定数量的失败后,断路器会打开。接下来,所有的请求在断路器打开的状态下,会快速地失败。一段时间

- 当断路器断开后,你有一些选项。其中之一是堆积请求,然后稍后重试它们。对于一些场景,这可能是合适的,特别是你所做的工作是异步作业的一部分时。然而,如果这个调用作为同步调用链的一部分,快速失败可能更合适。这意味着,沿调用链向上传播错误,或更微妙的降级功能
11. 5. 3 舱壁
- 在航运领域,舱壁是船的一部分,合上舱口后可以保护船的其他部分。所以如果船板穿透之后,你可以关闭舱壁门。如果失去了船的一部分,但其余的部分仍完好无损
- 正如我们下图看到的,如果一个连接池被用尽,其余连接并不受影响。这可以确保,如果下游服务将来运行缓慢,只有那一个连接池会受影响,其他调用仍可以正常进行

- 看看你的系统所有可能出错的方面,无论是微服务内部还是微服务之间。你手头有舱壁可以使用吗?我建议,至少为每个下游连接建立一个单独的连接池。不过,你可能想要更进一步,也考虑使用断路器
- 我们可以把断路器看作一种密封一个舱壁的自动机制,它不仅保护消费者免受下游服务问题的影响,同时也使下游服务避免更多的调用,以防止可能产生的不利影响。鉴于级联故障的危险,我建议对所有同步的下游调用都使用断路器。Netflix的 Hystrix库( https:// github. com/ Netflix/ Hystrix)是一个基于 JVM的断路器,.NET的 Polly( https:// github. com/ App-vNext/ Polly),或 Ruby的 circuit_ breaker mixin( https:// github. com/ wsargent/ circuit_ breaker)
- 在很多方面,舱壁是三个模式里最重要的。超时和断路器能够帮助你在资源受限时释放它们,但舱壁可以在第一时间确保它们不成为限制。Hystrix允许你在一定条件下,实现拒绝请求的舱壁,以避免资源达到饱和,这被称为减载( load shedding)
11. 5. 4 隔离
- 如果我们使用的集成技术允许下游服务器离线,上游服务便不太可能受到计划内或计划外宕机的影响
- 当服务间彼此隔离时,服务的拥有者之间需要更少的协调。团队间的协调越少,这些团队就更自治,这样他们可以更自由地管理和演化服务
11. 6 幂等
- 对幂等操作来说,其多次执行所产生的影响,均与一次执行的影响相同。当我们不确定操作是否被执行,想要重新处理消息,从而从错误中恢复时,幂等会非常有用
11. 7 扩展
11. 7. 1 更强大的主机
- 一些操作可能受益于更强大的主机。一个有着更快的 CPU和更好的 I/ O的机器,通常可以改善延迟和吞吐量,允许你在更短的时间内处理更多的工作。这种形式的扩展通常被称为垂直扩展,它是非常昂贵的,尤其是当你使用真正的大机器时。另一个问题是,这种形式的扩展无法改善我们服务器的弹性!尽管如此,
11. 7. 2 拆分负载
11. 7. 3 分散风险
- 确保不要让所有的服务都运行在同一个数据中心的同一个机架上,而是分布在多个数据中心
11. 7. 4 负载均衡
- 负载均衡器各种各样,从大型昂贵的硬件设备,到像 mod_ proxy这样基于软件的负载均衡器。它们都有一些共同的关键功能。它们都是基于一些算法,将调用分发到一个或多个实例中,当实例不再健康时移除它们,并当它们恢复健康后再添加进来
11. 7. 5 基于 worker的系统
- 负载均衡不是服务的多个实例分担负载和降低脆弱性的唯一方式。根据操作性质的不同,基于 worker的系统可能和负载均衡一样有效。在这里,所有的实例工作在一些共享的待办作业列表上。列表里可能是一些 Hadoop的进程,或者是共享的作业队列上的一大批监听器。这些类型的操作非常适合批量或异步作业。比如像图像缩略图处理、发送电子邮件或生成报告这样的任务
11. 7. 6 重新设计
- 你的设计应该“考虑 10倍容量的增长,但超过 100倍容量时就要重写了”
11. 8 扩展数据库
11. 8. 1 服务的可用性和数据的持久性
- 更直接地说,重要的是你要区分服务的可用性和数据的持久性这两个概念。你需要明白这是不同的两件事情,因此会有不同的解决方案
11. 8. 2 扩展读取
- 很多服务都是以读取数据为主的。扩展读取要比扩展写入更容易
- 服务可以在单个主节点上进行所有的写操作,但是读取被分发到一个或多个只读副本。从主数据库复制到副本,是在写入后的某个时刻完成的,这意味着使用这种技术读取,有时候看到的可能是失效的数据,但是最终能够读取到一致的数据,这样的方式被称为最终一致性

11. 8. 3 扩展写操作
- 一种方法是使用分片。采用分片方式,会存在多个数据库节点
- 分片写操作的复杂性来自于查询处理。查找单个记录是很容易的,如果你要查询所有的分片,要么需要查询每个分片,然后在内存里进行拼接,要么有一个替代的读数据库包含所有的数据集。跨分片查询往往采用异步机制,将查询的结果放进缓存。例如, Mongo使用 map/ reduce作业来执行这些查询。越来越多的系统支持在不停机的情况下添加额外的分片,而重新分配数据会放在后台执行;例如, Cassandra在这方面就处理得很好
- 扩展数据库写操作非常棘手,而各种数据库在这方面的能力开始真正分化。但长远来看,你可能需要看看像 Cassandra、 Mongo或者 Riak这样的数据库系统,它们不同的扩展模型能否给你提供一个长期的解决方案
11. 8. 4 共享数据库基础设施
- 一个正在运行的数据库可以承载多个独立的模式,每个微服务一个。这可以有效地减少需要运行系统的机器的数量,从这一点来说它很有用,不过我们也引入了一个重要的单点故障
11. 8. 5 CQRS
- CQRS( Command-Query Responsibility Segregation,命令查询职责分离)模式,是一个存储和查询信息的替代模型。传统的管理系统中,数据的修改和查询使用的是同一个系统。使用 CQRS后,系统的一部分负责获取修改状态的请求命令并处理它,而另一部分则负责处理查询
- 这解锁了处理扩展的大量方法。你甚至可以通过实现不同的查询方式来支持不同类型的读取格式,比如支持图形展示的数据格式,或是基于键 /值形式的数据格式
- 但要提醒大家一句:相对于单一数据存储处理所有的 CRUD操作的模式,这种模式是一个相当大的转变。我见过不止一个经验丰富的开发团队在纠结如何正确地使用这一模式!
11. 9 缓存
- 缓存是性能优化常用的一种方法,通过存储之前操作的结果,以便后续请求可以使用这个存储的值,而不需花时间和资源重新计算该值
11. 9. 1 客户端、代理和服务器端缓存
- 使用客户端缓存的话,客户端会存储缓存的结果。代理服务器缓存,是将一个代理服务器放在客户端和服务器之间。反向代理或 CDN( Content Delivery Network,内容分发网络),是很好的使用代理服务器缓存的例子。服务器端缓存,是由服务器来负责处理缓存,可能会使用像 Redis或 Memcache这样的系统,也可能是一个简单的内存缓存
- 我工作过的每一个面向公众的网站,最终都是混合使用这三种方法。不过对于几个分布式系统,我没有使用任何缓存。所有这些都取决于你需要处理多少负载,对数据及时性有多少要求,以及你的系统现在能做什么
11. 9. 2 HTTP缓存
- 首先,使用 HTTP,我们可以在对客户端的响应中使用 cache-control指令。这些指令告诉客户他们是否应该缓存资源,以及应该缓存几秒。还可以设置 Expires头部,它不再指定一段内容应该缓存多长时间,而是指定一个日期和时间,资源在该日期和时间后被认为失效,需要再次获取。标准的静态网站内容,像 CSS和图片,通常很适合使用简单的 cache-control TTL( Time To Live,生存时间值)
- 我们在 HTTP的兵器库里还有另一种选择:实体标签( Entity Tags)或称为 Etag。ETag用于标示资源的值是否已改变。如果我更新了客户记录,虽然访问资源的 URI相同,但值已经不同,所以我会改变 ETag
- 例如,假如我们想要获取一个客户的记录,其返回的 ETag是 o5t6fkd2sa。稍后,也许因为 cache-control指令告诉我们这个资源可能已经失效,所以我们想确保得到最新的版本。当发出后续的 GET请求,我们可以发送一个 If-None-Match: o5t6fkd2sa。这个条件判断请求告诉服务器,如果 ETag值不匹配则返回特定 URI的资源。如果我们的已经是最新版本,服务器会直接返回响应 304(未修改),告诉客户端缓存的已经是最新版本。如果有可用的新版本,我们会得到响应 200 OK、更新后的资源以及新的 ETag
- ETag、 Expires和 cache-control会有一些重叠,如果你决定全部使用它们,那么最终有可能会得到相互矛盾的信息!关于各种方式的优点的深入讨论,可以看一下《 REST实战》,或阅读 HTTP 1. 1规范的第 13章( http:// www. w3. org/ Protocols/ rfc2616/ rfc2616-sec13. html# sec13. 3. 3),它们描述了客户端和服务器应该如何实现这些不同的控制手段
11. 9. 3 为写使用缓存
- 使用后写式缓存,如果对写操作的缓冲做了适当的持久化,那么即使下游服务不可用,我们也可以将写操作放到队列里,然后当下游服务可用时再将它们发送过去
11. 9. 4 为弹性使用缓存
- 缓存可以在出现故障时实现弹性。使用客户端缓存,如果下游服务不可用,客户端可以先简单地使用缓存中可能失效了的数据
11. 9. 5 隐藏源服务
- 对于那些提供高度可缓存数据的服务,从设计上来讲,源服务本身就只能处理一小部分的流量,因为大多数请求已经被源服务前面的缓存处理了。如果我们突然得到一个晴天霹雳的消息,由于整个缓存区消失了,源服务就会接收到远大于其处理能力的请求。在这种情况下,保护源服务的一种方式是,在第一时间就不要对源服务发起请求。更合适的是,如果想优先保持系统的稳定,我们可以让原始请求失败,但要快速地失败

- 但当系统的一部分发生故障时,它是确保系统仍然可用的一种方式。让请求快速失败,确保不占用资源或增加延迟,我们避免了级联下游服务导致的缓存故障,并给自己一个恢复的机会
11. 9. 6 保持简单
- 避免在太多地方使用缓存!在你和数据源之间的缓存越多,数据就越可能失效,就越难确定客户端最终看到的是否是最新的数据
11. 9. 7 缓存中毒:一个警示
- 使用缓存时,我们经常认为最糟糕的事情是,我们会在一段时间内使用到失效数据。但如果发现你会永远使用失效数据,该怎么办?
- 使用 Expires: Never的页面,停留在很多用户的缓存里,永远不会失效,直到缓存已满或者用户手动清理它们。我们唯一的选择就是,改变这些网页的 URL,以便能够重新获取它们
11. 10 自动伸缩
- 响应型伸缩和预测型伸缩都非常有用,如果你使用的平台允许按需支付所使用的计算资源,它们可以节省更多的成本。但这也需要仔细观察你提供的数据。我建议,首先在故障的情况下使用自动伸缩,同时收集数据。一旦你想要为负载伸缩,一定要谨慎不要太仓促缩容。在大多数情况下,手头有多余的计算能力,比没有足够的计算能力要好得多!
11. 11 CAP定理
- 在分布式系统中有三方面需要彼此权衡:一致性( consistency)、可用性( availability)和分区容忍性( partition tolerance)。这个定理告诉我们最多只能保证三个中的两个
- 一致性是当访问多个节点时能得到同样的值。可用性意味着每个请求都能获得响应。分区容忍性是指集群中的某些节点在无法联系后,集群整体还能继续进行服务的能力
11. 11. 1 牺牲一致性
- 系统放弃一致性以保证分区容忍性和可用性的这种做法,被称为最终一致性
11. 11. 2 牺牲可用性
- 系统是一致的和分区容忍的,即 CP。在这种模式下,我们的服务必须考虑如何做功能降级,直到分区恢复以及数据库节点之间可以重新同步
- 例如 Consul(我们很快就会讨论到),设计实现了一个强一致性的键 /值存储,在多个节点之间共享配置
11. 11. 3 牺牲分区容忍性
- 为什么没有 CA系统呢?嗯,我们应如何牺牲分区容忍性呢?如果系统没有分区容忍性,就不能跨网络运行。换句话说,需要在本地运行一个单独的进程。所以, CA系统在分布式系统中根本是不存在的
11. 11. 4 AP还是 CP
- AP系统扩展更容易,而且构建更简单,而 CP系统由于要支持分布式一致性会遇到更多的挑战,需要更多的工作。对于库存系统,如果一个记录过时了 5分钟,这可接受吗?如果答案是肯定的,那么解决方案可以是一个 AP系统
11. 11. 5 这不是全部或全不
- 我们的系统作为一个整体,不需要全部是 AP或 CP的。目录服务可能是 AP的,因为我们不太介意过时的记录。但库存服务可能需要是 CP的,因为我们不想卖给客户一些没有的东西,然后不得不道歉。个别服务甚至不必是 CP或 AP的
- 你会经常看到关于有人打破 CAP定理的文章。其实他们并没有,他们所做的其实是创建一个系统,其中有些功能是 CP的,有些是 AP的
11. 11. 6 真实世界
- 无论系统本身如何一致,它们也无法知道所有可能发生的事情,特别是我们保存的是现实世界的记录。这就是在许多情况下, AP系统都是最终正确选择的原因之一。除了构建 CP系统的复杂性外,它本身也无法解决我们面临的所有问题
11. 12 服务发现
DNS
- DNS有许多优点,其中最主要的优点是它是标准的,并且大家对这个标准都非常熟悉,几乎所有的技术栈都支持它
- 域名的 DNS条目有一个 TTL。客户端可以认为在这个时间内该条目是有效的。当我们想要更改域名所指向的主机时,需要更新该条目,但不得不假定客户至少在 TTL所指示的时间内持有旧的 IP
- 绕过这个问题的一种方法是,如图 11-9所示,让你的域名条目指向负载均衡器,接着由它来指向服务实例。当你部署一个新的实例时,可以从负载均衡器中移除旧的实例,并添加新的实例

11. 13 动态服务注册
- 作为一种在高度动态的环境发现节点的方法, DNS存在一些缺点,从而催生了大量的替代系统,其中大部分包括服务注册和一些集中的注册表,注册表进而可以提供查找这些服务的能力
11. 13. 1 Zookeeper
- Zookeeper( http:// zookeeper. apache. org/)最初是作为 Hadoop项目的一部分进行开发的。它被用于令人眼花缭乱的众多使用场景中,包括配置管理、服务间的数据同步、 leader选举、消息队列和命名服务(对我们有用的)
- 像许多相似类型的系统, Zookeeper依赖于在集群中运行大量的节点,以提供各种保障。这意味着,你至少应该运行三个 Zookeeper节点
11. 13. 2 Consul
- 和 Zookeeper一样, Consul( http:// www. consul. io/)也支持配置管理和服务发现。但它比 Zookeeper更进一步,为这些关键使用场景提供了更多的支持。例如,它为服务发现提供一个 HTTP接口。Consul提供的杀手级特性之一是,它实际上提供了现成的 DNS服务器。具体来说,对于指定的名字,它能提供一条 SRV记录,其中包含 IP和端口。这意味着,如果系统的一部分已经在使用 DNS,并且支持 SRV记录,你就可以直接开始使用 Consul,而无需对现有系统做任何更改
11. 13. 3 Eureka
- Netflix的开源系统 Eureka( https:// github. com/ Netflix/ eureka),追随 Consul和 Zookeeper等系统的趋势,但它没有尝试成为一个通用配置存储。实际上,它有非常确定的目标使用场景
11. 13. 4 构造你自己的系统
- 如果你希望当下游服务的位置发生变化时,上游服务能得到提醒,就需要自己构建系统
11. 13. 5 别忘了人
- 无论你选择什么样的系统,要确保有工具能让你在这些注册中心上生成报告和仪表盘,显示给人看,而不仅仅是给电脑看
11. 14 文档服务
- 通过将系统分解为更细粒度的微服务,我们希望以 API的形式暴露出很多接缝,人们可以用它来做很多很棒的事情。如果正确地进行了服务发现,就能够知道东西在哪里。但是我们如何知道这些东西的用处,或如何使用它们?一个明显的选择是 API的文档
11. 14. 1 Swagger
- Swagger让你描述 API,产生一个很友好的 Web用户界面,使你可以查看文档并通过 Web浏览器与 API交互。能够直接执行请求是一个非常棒的特性
11. 14. 2 HAL和 HAL浏览器
- HAL( Hypertext Application Language,超文本应用程序语言, http:// stateless. co/ hal_ specification. html)本身是一个标准,用来描述我们公开的超媒体控制的标准
- 如果你在使用超媒体,我更推荐使用 HAL而不是 Swagger。但是如果你没有使用超媒体,也不能判断将来是否切换,我肯定会建议使用 Swagger
11. 15 自描述系统
- 在 SOA的早期演化过程中, UDDI( Universal Description, Discovery, and Integration,通用描述、发现与集成服务)标准的出现,帮助人们理解了哪些服务正在运行。这些方法都相当重量级,并催生出一些替代技术去试图理解我们的系统
11. 16 小结
- 推荐 Nygard的优秀图书 Release It!。在书里他分享了一系列关于系统故障的故事,以及一些处理它们的模式。这本书很值得一读(事实上,我甚至认为它应该成为构建任何规模化系统的必读书籍)