「从小白到架构师」系列努力以浅显易懂、图文并茂的方式向各位读者朋友介绍 WEB 服务端从单体架构到今天的大型分布式系统、微服务架构的演进历程。在「从小白到架构师」系列的第一篇《应对高并发》中,我们介绍了通过缓存、横向扩容、消息队列、分布式数据库等基础设施来提高系统并发量的方法。在实际开发中业务逻辑比基础设施更加灵活多变且更容易出故障,架构设计不仅需要考虑基础设施的建设,同样需要关注业务开发的便利以及应对业务系统的故障。
还是从博客开始
还是从我们熟悉的博客网站开始,小明是个喜欢写博客的程序员,他觉得市面上的博客网站都太丑了,就想自己搞一个。说干就干,小明抄起LAMP(Linux-Apache-MySQL-PHP)一把梭三下五除二就把网站搞了起来,网站的名字就暂定为「淘金网」😏
由于淘金网界面美观方便好用, 越来越多的网友开始入驻淘金网来耕耘自己的一小片天地。渐渐地用户们觉得只能写博客功能太单一了,有些用户想要把系列文章编辑成电子书、有些用户想要做直播分享,有些用户想要在这里发状态,小明也想要卖点会员补贴一下服务器的支出…… 没关系都可以有,继续一把梭加逻辑就是了:
日子也就这么一天一天的过下去,淘金网逐渐从个人小站发展成了一家小有规模的创业公司。
- 博客、电子书、微博每个模块都向用户、订单这些表里塞了一堆字段,一动线上就出 BUG, 谁都不敢动。
- 即使上线一个小功能也要发布整个网站,有时候会不小心带上了一些未经测试的代码,有时候会在意想不到的地方出了错误。
- 一个业务容量不足需要加机器, 就相当于给所有模块做了扩容,白白支出了其它模块的固定开销(无负载的服务所消耗的资源,比如 Spring 容器消耗的大量内存,后台线程消耗的 CPU)。
这些都还可以忍,直到那个阳光明媚的早晨小明美滋滋打开后台想要看一眼今天的入账时,却发现网站打不开了。。。
检查日志发现,在编写创作者中心的逻辑时不小心写了一个不停向数组中插入元素的死循环,过不了多久服务器就会 OOM 崩溃掉,只有 supervisord 还在徒劳的尝试重启服务器。。。
明总是个未雨绸缪的人, 他觉得BUG 是无法杜绝的,这样的故障早晚会再次发生。 小明灵机一动决定把博客、电子书、会员、直播这些业务拆分成独立的服务端程序,一个模块拉起一个进程,这样任你 OOM 还是 panic 都不会影响其它业务,万一出了什么故障损失也就小得多了。
小明发现把服务拆开后一些老问题也解决了:未测试代码被误上线的情况几乎没有了;各个模块可以按照需求各自规划服务器资源了;而且还有个意外之喜,每个模块可以用不同的技术栈, 前端可以用 node.js 做 BFF, 数据分析可以用 Hadoop, 推荐系统可以用 Python...
还有个问题没有解决,不同模块依旧依赖同一个数据库,表结构牵一发而动全身,每次修改都小心翼翼如履薄冰。小明决定一鼓作气,将数据库也按照业务板块进行拆分,每个模块只允许读写自己的数据库,需要其它模块数据时一律调接口,禁止直接访问数据库。
ok, 现在一切都是那么的完美,岁月如此静好。。。
服务治理的难题
话说自从服务拆分之后再也没有出现过全站崩溃的事故,小明美滋滋的准备开个年会,大家拿了年终奖回家过年。就在年会上,小明听到程序员们在抱怨:
- “支付系统一到有活动就扩容,活动结束就把临时加的机器下掉,其它人也得跟着改配置文件才能找到服务地址”
- “一个请求经过了好几个模块,出了 BUG 找半天都查不清是哪个模块的故障”
- “一上促销商城那边的调用就特别多,差点把我们的支付压垮,直播的人就来抱怨说没法刷礼物”
- “会员那边不靠谱,付款失败还照样发会员,损失好多钱”
小明把这些问题一一记录下来,开始寻找答案。
服务发现
支付系统一到有活动就扩容,活动结束就把临时加的机器下掉,其它人也得跟着改配置文件才能找到服务地址
我们服务部署在不同的服务器上,而且会随着负载情况不时的增删机器,调用方如何及时准确的获得服务的地址?实例之间如何均衡负载?我们将这个问题称为服务发现。
DNS 系统也可以算是一种服务发现,服务提供方的节点在启动后向 DNS 注册自己的地址,节点下线前将自己从 DNS 的节点列表中删除。服务调用方通过域名向DNS查询服务提供方的实际地址,DNS 会在节点列表中按预定策略挑选一个节点的 ip 地址返回给调用方。
在实际使用中更多的还是采用 Zookeeper、Consul、Etcd 等高一致性的 KV 组件做服务发现:
服务发现系统会通过心跳包等机制检查节点健康状态,并屏蔽不健康的节点。这样即使节点在崩溃前没有向配置中心报告故障,服务发现也能避免请求继续到达异常节点:
因为服务发现可以方便的控制调用方访问的节点,所以也常常用来实现灰度发布,A/B测试等功能:
限流、熔断、降级
一上促销商城那边的调用就特别多,差点把我们的支付压垮,直播的人就来抱怨说没法刷礼物
虽然服务拆分之后单个进程崩溃不会波及其它进程,但是若下层的服务的实际负载超出了最大吞吐量出现响应过慢或超时的情况仍然可能波及其它上层服务。
因此,有必要在服务之间设置保护机制,防止小故障的影响不断扩大,最终造成大面积的雪崩。常用的保护机制有三种:
熔断:当某个服务或节点的调用失败数或调用耗时超过阈值时,调用方应停止继续调用此节点并快速返回失败。防止自身请求大量堆积,整条链路浪费大量资源等待下游响应。
降级:当下游服务停止工作后,如果该服务并非核心业务,则上游服务应该降级,以保证核心业务不中断。
限流:当服务提供方的负载接近最大处理能力时可以丢弃请求并立即返回失败,防止大量堆积的请求将自身压垮。或者当某个上层服务的调用量过大时丢弃它的(部分)请求,避免自身崩溃影响其他上层服务。
链路追踪
一个请求经过了好几个模块,出了 BUG 找半天都查不清是哪个模块的故障
要想查清故障原因就需要记录一个请求在系统中经过了哪些模块以及模块之间的调用关系,进而找到故障模块的相关日志,这种在服务系统中追踪调用关系的技术称为链路追踪。
链路追踪的原理是在请求进入系统时为它分配一个唯一的 traceID (通常使用雪花算法生成), 这个请求调用过程中产生的所有日志数据都要带上这个 traceID 并上报到统一的日志数据库。事后分析时只要使用 traceID 进行查询就可以找到相关日志了。
比较出名的链路系统是 Google 的 Dapper,有兴趣的朋友可以点链接看一下详细的资料,这里就不展开讨论了。
分布式事务
会员那边不靠谱,付款失败还照样发会员,损失好多钱
付款和变更订单状态两个操作应该是原子的,要么都执行要么都不执行,不应该出现一个执行另一个不执行的情况。这是典型的数据库事务问题,在我们将数据库拆分之前这个问题可以交给数据库的事务机制解决,但是数据库拆分之后一个事务涉及到多个数据库实例甚至是异构的数据库,这就需要一些分布式事务协调组件来处理了。
常见的分布式事务实现方案有 TCC(try-confirm-catch)事务、MQ 事务消息、Saga 事务等。分布式事务主要有两种实现思路,第一种的典型代表是 TCC 事务,TCC 事务分为三个阶段:
- Try 阶段: 事务协调器要求参与方预留并锁定事务所需资源;
- Confirm 阶段: 若所有参与方都表示资源充足可以提交,事务协调器会向所有参与方发出 Confirm 指令,要求实际执行事务。
- Cancel 阶段: 若 Try 或 Confirm 阶段任一参与者表示无法继续事务协调器会向所有参与方发出 Cancel 指令解锁预留资源并回滚事务。
第二种实现思路的典型代表是 Saga 事务,Saga 事务将一个大事务拆分成多个有序的子事务并且每个子事务都准备了撤销操作,事务协调器会顺序的执行子事务,如果某个步骤失败,则根据相反顺序一次执行一次撤销操作。
上面我们只简单介绍了分布式事务保证原子性的机制,在实际实现中还要考虑分布式事务的一致性(强一致还是最终一致)、隔离性(Saga 事务会暴露事务执行到一半时的状态)、对业务的侵入性、并发量等各种问题,简言之分布式事务是一种非常复杂、成本很高的技术。
由于分布式事务的高成本,在在实际开发中经常使用「对账」的方式来保证多模块事务的最终一致性,即用离线任务定时扫描数据库找出未正确处理的事务,然后按照预定策略进行补偿(比如撤销未成功付款用户的会员身份)或者要求人工介入修复。
微服务时代的基础设施
容器化和 Kubernetes
淘金网从最开始便是直接部署在云服务器上的,到了后来做了服务拆分也依旧没有改变。每次扩容都要等待新的云服务器慢慢启动、跑脚本装环境最后拉起服务进程,一等就是半天。需要升级 JRE 的时候还要写脚本一台一台连上去进行升级,有时候还会升级失败需要人工介入进行处理。 还有些时候为了充分利用资源会在一台云服务器上部署好几个服务,这些混部的机器管理起来也是各种麻烦。
这一切都让程序员们苦不堪言,于是小明又开始了调研,这时一种叫「容器化」的新技术吸引他的视线。
我们都知道计算机可以分为三层:硬件、操作系统和应用程序。所谓的云服务器本质上是虚拟机,虚拟机可以模拟硬件的接口,这样做最大的好处是可以在虚拟机上运行与宿主机不同的操作系统程序,比如我们可以 Windows 系统上运行 Linux 虚拟机。但是,操作系统内核的计算量十分庞大,在软件模拟出的硬件上运行其性能可想而知。
对于云服务器而言并不需要再运行一个操作系统内核,云服务器只是需要独立的目录树、进程空间、协议栈就可以了,就是说即使云服务器 A 和 B 运行在同一台的宿主机上 A 的根目录和 B 根目录是独立的。
容器化技术的实质是模拟操作系统内核,实际上运行的只有宿主机一个操作系统内核,但是宿主机上的每个容器都认为自己拥有一个独立的操作系统内核。
Docker 是目前容器技术的事实标准,它使用使用 Linux Namespaces 技术隔离目录树、进程空间、协议栈等,使容器之间互不影响;使用 cgroups 机制分隔宿主机的 CPU、内存等资源。
Docker 的另一个重要贡献是定义了容器镜像的标准。 Docker 镜像使用分层文件系统 AUFS。每层数据一旦提交便不可改变,只能添加一个新层将其覆盖。Docker 镜像的不可变性保证了运行环境的一致,免除了登录云服务器装环境的痛苦,一致的运行环境也减少了「测试环境是好的,怎么一上正式环境就出问题了」的发生。 分层文件系统使得每次打包 Docker 镜像只需要更新业务二进制,比虚拟机镜像小很多。Docker 允许将任何现有容器作为基础镜像来使用,极大的方便了重用。
Docker 只提供了单机上的容器化支持,而我们的生产环境是由很多服务器组成的,有些模块负载不足需要横向扩容多加几个容器,有些宿主机会宕机需要将上面运行的容器换台宿主机重启。解决这个问题的是大名鼎鼎的 Kubernetes, Kubernetes 不仅可以完成服务编排的工作,而且提供了描述集群架构的规范。我们通过编写 yml 定义集群的最终状态,Kubernetes 可以将系统自动达到并维持在这个状态。这种能力将人力从繁重的运维工作中解脱出来,实现了方便可靠的部署和扩缩容。
Service Mesh
由于微服务需要提供服务发现、熔断限流等服务自治能力,所以微服务框架所要提供的功能比传统的 Web 框架多很多。看到现在仍不少见的 Centos7、Java 5、Struts2 等各种老旧的基础设施就可以想象升级基础框架是一件多么痛苦的事情。
为了解决基础架构组和业务组之间为了升级框架带来的疯狂扯皮,小明找到了一个新的思路。这种方式称为 Service Mesh,它将服务发现、认证授权、调用追踪等服务治理所需的能力放到一个被称为 SideCar 的代理组件中,所有出站入站的流量都通过 SideCar 进行处理和转发, 业务方只需要和 SideCar 进行通信即可。
Service Mesh 中还有个被称为控制面(Control Panel) 的组件来统一管理所有 Sidecar 的配置,SideCar 和业务组成的部分称为数据面(Data Panel), 控制面和数据面共同组成了 Service Mesh 架构。
因为 Side Car 和业务只通过 RPC 进行通信,两者可以独立升级,免去了升级基础框架时需要改动业务代码的种种麻烦。由于 RPC 调用天生可以跨语言,所以只需要开发一次 SideCar 就可以对接多种语言开发的业务系统。
ServiceMesh 直译是服务网格,大概是因为架构图比较像网格才起了这个名字吧~
总结
何谓微服务
又是一年年终季,小明看着自己搞的这么多东西打算整个 PPT 去行业交流会上吹吹牛。他左翻右找,终于找到了一篇论文:Microservices: a-definition-of-this-new-architectural-term, 原来自己做的这套结构有一个好听的名字:「微服务」:
「什么是微服务」这个问题是典型的一千个人眼里有一千个哈姆雷特,不过回顾「淘金网」的历程我们发现有一些理念已经是业界的共识:
- 按照业务板块将单体大服务拆分为多个独立的小服务,通过分割解耦的方式控制代码的复杂度。小服务的独立性赋予了它更高的灵活性,比如采用异构技术和异构架构的自由。分散部署也有效的阻止了局部错误造成大范围的故障。
- 数据去中心化: 各个服务独立维护数据库,降低模块之间的耦合程度。
- 重视服务治理,但鼓励各模块自治。微服务不仅仅是将服务拆分,而且重视处理拆分后出现的一系列问题,比如控制流量路由的服务发现系统;避免连锁故障的限流、熔断、降级技术;用于调试和排查的链路追踪系统;以及维护事务安全性的分布式事务机制。服务治理能力不是由中心或者基础架构强加给各模块的,而是各模块根据自己的需要灵活选择治理能力和实现方式。
- 重视弹性:服务的部署容量不是固定的,而是根据业务需求量随时增加或减少节点数,并且因此促进了 Kubernetes 等弹性平台的广泛使用。
- 重视弹性:服务系统应该可以按照业务需要灵活的进行扩缩容。
下集预告:揭开分布式系统的面纱
很多同学一提到分布式系统便想到 CAP 理论、Paxos 算法、Hadoop 等吓人的名词,甚至失去了继续学习的勇气。「从小白到架构师」 系列的前两篇几乎每句都与分布式系统密切相关,第三篇文章「揭开分布式系统的面纱」我们将一起探索分布式系统的各种技术和问题:如何编写在分布式环境中运行的业务代码?什么是分布式共识问题,又有哪些解决方案?CAP 定理是什么,又有哪些例证?那些经典的分布式数据库又是如何工作的?