为什么直接谈分布式架构呢?其实好多年前的应用软件基本就是分布式的,从早期的C/S,到B/S模式,到多层架构,微服务架构,组成一个应用平台,需要独立运行的系统越来越多,比如WEB服务器,各种中间件、业务系统、数据库、缓存以及若干辅助系统,比如监控,统计等。比如银行从手机银行完成一笔转账,可能需要经过:
如果是跨行汇款还有可能需要经过人行现代化支付系统。这是是简单的示意,实际任何一个银行系统环境都只会比上述更加复杂。
如此复杂的系统环境,会对系统的数据一致性、可用性都提出了很高的要求,同时对实现交易高并发、低延时难度增加。尤其在银行的系统,基本上都是账务交易,都要求数据的强一致性,对于高并发的要求则难度更高了。
谈分布式架构,不得不提几个广为人知的理论:
CAP
理论中分布式系统的可用性和一致性不可兼得的问题。BASE 理论包含以下三个要素:
我们都知道,传统的关系数据比如Oracle
都是支持事务的,事务最重要的特性就是ACID
,也就是保证了强一致性。不管何时,只要对事务执行了提交(COMMIT
)操作之后,别的进程/线程读到的数据一定是更新后的。如果执行回滚(ROLLBACK
)操作,则事务中操作的所有改变都放弃,恢复到原来的状态。
依据CAP
理论,三者不可兼得,最多只能同时满足两个特性。传统关系数据库在单点保证了C(强一致性)和A(可用性)。对于数据库的集群,在CAP
理论广泛流行起来之前,数据库厂商似乎努力让我们相信数据库是无所不能的,比如通过昂贵的存储设备共享磁盘的方式实现集群。但现在我们知道了,如果存在分区P,而且要容忍分区,又要保证强一致性C,那么就只能放弃A(可用性)了。这就意味着响应时间可能会无限延迟。
因此,对于银行核心系统的数据库来说,既要必须保证数据强一致性,又需要保证高可用,核心数据库往往会选择部署在昂贵的高性能小型机上,采用主从高可用(HA)方案。也就是同一时间只有一个服务器拥有负载,另外一台闲置。负载的节点一旦出现问题,迅速切换到闲置节点上。以此消除单点故障。
开源数据库在允许响应时间在可控范围的延长前提下,采用同步复制到集群中的多个节点的方式,只有全部节点同步完毕,事务才完成。或者在响应时间和一致性中相对灵活的控制,采用异步方式进行多节点数据同步,用以实现:
这叫读写分离。如此,部署再多的从节点,也不会影响数据更新的效率。
这里提到允许一定程度数据延时同步
,这恰好是BASE
理论的基础了。是不是所有的数据更新都一样要立马体现出来呢?身边同事多是从事银行系统开发的,对他们来说,可能觉得大部分重要的交易都是的,比如转账,取款,存款,消费等。毕竟,在多渠道服务已成常态的时代,如果消费金额不能实时体现在用户的账户中,很有可能造成银行的损失。但是对于更广泛的场景而言,其实真不是太多交易有强一致性要求的。所以,有人问我如何提高系统性能的时候,我一般第一个回答是让他们先把交易理清楚,先找出有强一致性要求的数据出来。我把这叫强弱分离。强一致性部分没什么太好的办法,但弱一致性要求的部分,可采取的方法就多了,最基本的,通过消息机制异步化处理过程。这就是Eventually Consistent
——最终一致性。就是可以过一会儿,数据才是一致的。这之前,有脏数据,可能没人在意,或者压根没人用。这里就有很多例子了,比如在电商系统里面:
等等例子。只要记住一点,最终一致性,并不是放弃一致性,必须建立最终实现一致性的机制。
另外,确实有一些数据,可以容忍某种程度的丢失,比如阅读量,这样的数据更可以一定程度放弃一致性已达到更高的可用性了。
分布式系统中,由于总会面对分区(P)的可能性。所以,生成的一致性(C)、可用性(A)就只能选择一个了。PC的组合往往会导致系统响应时间变长,以至于不可用。因此,PA+最终一致性成为分布式系统最常用的选择了,基本就是被表述为BASE
了,与ACID
站在了两个极端。
CAP
理论作者Eric Brewer在《CAP Twelve Years Later: How the “Rules” Have Changed》一文中提到,分区
并不是总出现的。
Because partitions are rare, CAP should allow perfect C and A most of the time, but when partitions are present or perceived, a strategy that detects partitions and explicitly accounts for them is in order. This strategy should have three steps: detect partitions, enter an explicit partition mode that can limit some operations, and initiate a recovery process to restore consistency and compensate for mistakes made during a partition.
这里提到了三个步骤应对分区出现:
比如探知到网络中断的时候的选择:
但往往有一种可怕的情况,就是网络中断并不是在交易发出之前,而是过程中。可能无从判断交易的实际状态。在网络恢复之后,往往采用反交易的方式,取消之前的操作。这其实也是放弃了强一致性了。在银行系统中,这种做法是常用的。文中提到的ATM取款限额的方式,低于限额可以先给钱的情况,在国内基本是不可能采用的。
无论如何,文章给了我们很多提示,尤其在如何应对分区
出现。由此也出现了一些所谓“金融级”的分布式强一致性数据库。但我认为,这完全就是用户对于产生数据不一致的风险的接受程度了。
讨论技术实现的时候,最近几年总会有人提到“互联网技术”,把一大堆开源组件、工具放到一起论证、比较好坏。其实挺没意思的。工具确实有好坏,但坏的东西,基本上也流行不起来。功能差不多的工具也具有不同的特点,设计上也有所区别和侧重,但基本上都可以满足大部分的需求,而且大部分这类工具的文档都挺好的,很容易找到区别。虽然选择一个合适的工具很重要,但千万别把架构师的工作就放在堆积木上。
实现一个分布式系统,有几个层面的工具/技术很重要:
当然,还有很多很重要的技术点或者设计/架构模式,比如微服务,基于微服务的数据一致性保障等。
一个可能的分布式架构的实现如下图:
显然,这个架构是适合交易系统(OLTP),比如电商,手机银行等各类交易服务系统。不适用于数据处理或分析系统(OLAP)。其实这里叫架构,也不一定合适,更像一个脚手架,用各种工具先把一个系统的外围支撑起来,至于业务的实现,则会体现在具体的服务的开发上,这部分是硬功夫(编码)。
这个框架可以分为几个部分:
其内核部分,也就是业务逻辑实现的框架依赖Spring Cloud搭建。确实是搭建,主要用到几个组件:
诚然,有很多可替换的部分,比如网关使用Netflix Zuul,集中配置使用Apollo等。但可以看出,几乎除了自己的应用逻辑之外,一个软件平台应该有的外围功能都有了。
Spring Cloud本身也提供了更多的工具包/组件,而且在快速迭代发展。基本上,会比我们开发应用还快。只能说随着时间的推移,选择最合适自己的组件了。
应用开发的框架,在JAVA的世界,毫无疑问,SpringBoot是不二选择了。不但可以快速构建项目,自带运行中间件环境,如Tomcat、Jetty等,尤其是应用部署、运行非常方便。
尤其对于微服务架构的模式,每个运行的服务,都应该是轻量级,容易部署,容易监控,甚至容易停止/替换。可能有人也会说Spring Boot各种不好,配置不清晰等。但如果原来一直需要把JAVA程序费劲的部署在中间件过来的人,真是觉得神清气爽的。
应用逻辑的开发,基本都在每一个微服务
里面了,比如电商系统的商品模块、支付模块、订单模块、促销模块、内部账户模块、客服模块等。当然,需要具体拆分成多少个微服务,就看自己的设计了。但在一个服务里面,遵循最基本的Controller+Service+Dao
三层的划分模式,基本可以满足大部分——我认为只要数据模型设计的好的话。
数据访问方面,我个人比较传统,喜欢偏向SQL的工具,比如MyBatis,很多操作配合一下SQL轻松搞定,执行效率还高。当然我不是抨击ORMapping类的组件不好,比如hibernate,也很好。甚至现在可能很多年轻的程序员写不出高效的SQL,还不如生成的。总而言之,我对数据层的理解就是:
关于服务内部的开发,还有最后一点就是日志。后面会提到ELK
日志监控,但首先,是要记录好日志。一个典型的微服务可以看做是一个没有界面的服务系统,响应一定的请求、对请求做内部处理、操作数据库、调用其他服务四个部分组成。
比如在银行体系中,卡系统一般都是独立部署的,对外提供基于卡的转账、取款、存款、消费等交易。如转账,内部处理获得对应的账号,并记录交易流水之后,调用核心系统执行转账交易。而在电商的支付处理中,内部处理记录支付日志,而后调用第三方支付接口。
当然,在好的服务设计中,应该尽量减少服务之间的来回依赖,这个后面再说。
记录日志我的一般原则是:
我们知道,系统上线之后,绝大部分的BUG都应该被排除了。可能出现的问题,一般是是如下几种:
因此,发生异常需要记录日志,一定要把异常放生的上下文(请求参数,正在处理的对象等)记录下来。否则,看到异常,也不知道为什么发生异常。
当然,也需要灵活处理,比如一个接口返回一个表格的数据,就别记录了,开发过程多测试几次。那种每几秒钟检查一下某种状态的日志,是很讨厌的,最好用监控来处理,不要记在日志了。
对于这种后台服务系统,开发人员最好习惯于不要依赖单步调试,而应该熟悉的看自己的日志来进行调试。且不说很多多线程并发引起的异常单步调试的时候没办法发现,更要想到,如果一个系统上线了,被报告发生错误,你能拿到的只有日志文件,几乎没有机会、或者非常困难重现问题的。尤其不具备准生产仿真环境的场景,几乎只能抓瞎。
对于分布式系统而言,最基础的组件(不包含硬件、操作系统等)应该有:
这些基础设施是如此重要,以至于几乎所有分布式系统都会使用到。而且,不仅仅是一个独立安装部署的工具(当然,在工具层面也很重要,必须至少能消除单点故障并保证信息的安全),而且对于整个系统的设计模式都有很大的影响。用什么类型的数据库,用或者不用缓存、队列都是直接影响系统设计的核心点。甚至包括缓存和队列如何用、用在哪里,都是严重的架构决策。
服务接入其实就是HTTP请求的接入大门了。目前绝大部分应用系统都是基于HTTP/HTTPS协议提供服务了。别的协议接口基本也不想讨论了。HTTPS+RESTful+JSON
可以说是继SOAP
之后一个没有啥创新但却很了不起,迅速为大家所接受并推崇的接口方式了。
这里异步抗高并发的Nginx
貌似是唯一的选择了。尤其在作为静态资源处理、反向代理、负载均衡方面,Nginx都很强大,性能很高,而且很稳定,基本上不会挂。
当然有人追求更高稳定性,用Apache也是不错。毕竟,更加成熟的产品,传统WEB服务中依然是主流。
作为服务接入的统一入口,要注意的是这部分很可能是一个单点故障的存在。如果有条件,前面用如F5这样的硬负载均衡器就更好了。如果没有,用两个Nginx+Keepalived做HA方案是必要的。
充分利用互联网资源,只把必要的HTML文件放在Nginx服务器上,JS,CSS以及图片视频等都放到CDN、OSS或者其他便宜的托管服务上。降低主服务的处理压力和带宽压力。
监控对于一个系统的正常运行来说是非常重要的。可以及时知道系统的各种状态,如CPU、内存、网络的使用状态,以及各类请求处理情况,失败率,处理时长的。可能还有一些业务相关的状态、统计等。要很好的显示各种监控其实是挺费劲的。所幸现在有各类成熟的工具,直接部署起来就可以用了。当然,需要展现的数据还是需要在应用系统中提取出来。
一般监控有几类:
监控除了应该有可视化界面之外,应该可以确定规则,根据规则通知管理员或者相关用户。比如通过短信、微信、邮件等。
监控的开源产品很多,各有侧重点。但总体而言,监控本身是一个很“重”的服务,毕竟,一个运行的业务会有大量的交易,同时产生大量的日志文件。监控系统需要对这些数据进行采集、存储、分析并展现。但无论如何,为了业务的健康发展,还是需要建立一套合适的监控体系的——无度量,无改进。
这里引用一张图来说明各类监控的关系:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mb00Z2AN-1583058823139)(https://peter.bourgon.org/img/instrumentation/02.png)]
图片出自:Metrics, tracing, and logging
各类软件其实并不严格区分上述几类监控,往往功能会有重合。
日志监控是最基础的监控,开发人员会在程序中输出大量的信息,尤其是错误信息,一般情况开发人员并不会刻意把错误信息发送到一个可以被直观的看到的地方。前文已经提到了一些记录日志的实践指导。在完善的日志基础上,日志监控就可以起到很好的作用,包括:
一个好的日志监控工具,确实能起到很大的作用。一般日志有那么几类:
日志监控这个,ELK特别成熟,基本上不需要犹豫。其中必须用到的Elasticsearch以及传送日志扩展使用的Kafka或者Redis,都是分布式架构中最常用组件,基本可以作为一个公共的基础组件对待。ELK实际是三个工具组成:
日志监控的工具本身比较成熟,主要还是在应用日志方面确定一个日志的规范,便于解析。
调用链在微服务体系下是一个很重要也很有用的东西。不但在发生错误的时候知道链条中那个环节出错,还可以让开发人员全面的了解每一个业务处理的全面流程。毕竟,几乎所有项目一开始设计的时候都会有清晰的架构图、流程图,很清楚的描述每个流程都需要经过哪些环节,但时间长了,经过几个人的维护、升级之后,可能就不可控了。虽然我们都强调文档,但都知道文档基本不可靠,尤其哪个项目都是时间紧、人手缺的状态下。实际经验告诉我,很多对外宣称很完善的开发管理体系,进去之后,你会发现几乎没人说得清楚一个具体的业务流程实现都需要经过哪些模块,更别说到接口级别了。曾经我加入一个项目,我花了两个星期,把所有的流程梳理出来之后,甲方项目经理目瞪口呆,觉得不可思议。
插个题外话,用程序来管理程序员,是一个比较有意思的事情。在一定的技术体系下,程序员做的所有事情,都能可视化的展现出来,比如这里的调用链监控,又比如通过Swagger生成API文档。
调用链有一个叫openTracing的标准。大概的框架如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XZ1LCBeR-1583058823141)(https://skywalking.apache.org/assets/img/openTracing_base_structure.e1a47387.png)]
图片来自:APM和调用链跟踪
其中:
(上述文字同样来自:APM和调用链跟踪)
简单来说,就是一个Trace就是一组Span组成的有向树状图了。
但也不得不说,市面上参考这个概念的产品挺多,但满足这个规范的貌似并不多。这个规范也有待发展。
调用链监控的产品出名的也有好几个。这简单的介绍一下:
从总体架构上来看,调用链监控的产品大同小异,基本都是分为如下几个部分:
CAT、Pinpoint和Skywalking都提供JVM的监控。甚至CAT还通了简单的度量监控。
总而言之,不管用哪个,都得自己尝试以下,适合的才是最好的。
度量指标监控通过Metrics来进行。很规矩的一个系统。
Metrics一般有5种基本的度量类型:
这五类度量指标非常完善,基本上各种业务指标都能知道了。其他一些工具针对度量指标的统计,比如CAT,Spring Boot Admin基本上只提供Gauges和Counters两种。
度量类主要采用时序数据库(TSDB)
的解决方案。它是以事件发生时间以及当前数值的角度来记录的监控信息,是可以聚合运算的,用于查看一些指标数据和指标趋势。所以这类监控主要不是用来查问题的,主要是用来看统计和趋势的。
时序数据库一般可以用KairosDB或者InfluxDB。而针对时序数据库最好的展现工具是Grafana。但很不爽的是,Grafana
已经默认不支持KairosDB
,而InfluxDB
已经不开源集群版本。但作为监控,不是业务核心部分,还是可以用的。也就是三个工具配合起来:
采集数据(Metrics)-> 存储数据(InfluxDB) -> 显示数据(Grafana)
形成业务指标的监控。
众多的服务组成一个应用体系,对每个服务的健康检查就很重要了。通常健康检查主要两类:
HTTP
的服务检查,这也是数量最多的服务,可能还有一些基于Socket
的服务这里就不说了。针对HTTP
服务,主要的手段是在特定时间间隔向特定URL
发送请求,判断响应时间、响应状态码或者特定的响应数据,用以判断服务是否健康。MySql
发出SELECT 1 FROM DUAL
这样的语句,看是否能应答。一般现在服务的运行环境都采用虚拟化、容器技术,所以,主机相关的监控,一般虚拟化平台本身会提供。但针对服务本身的健康检查,意味着一个好的服务,应该有自我检查的API提供,这个API应该能覆盖带所需要检查的所有点。
比较简单的方案,使用:Spring Boot Actuator + Spring Boot Admin(SBA)方案。Actuator为服务增加一组健康检查的API,SBA则覆盖这些API,并可视化展现结果。
如果不怕麻烦的同学,可以用德国电商公司Zalando开源的一款健康检查和告警平台ZMon,很强大,但也比较复杂,得装一大堆东西。这东西本质上是一个分布式的任务调度系统,调度自定义的Python
任务,在这里就是发送各种请求,如针对Spring Boot Actuator的健康检查端点,读取KariosDB中的Metrics等。同时也支持自定义的检查程序开发。不过就是门槛比较高了。
上述谈到的各种监控的工具,基本都具备一定的警告通知能力,一般都会按照公司的策略,对于需要紧急处理的异常进行通知,通过邮件,短信,最好还有微信三种渠道发送通知。
现在分布式架构实现中,有一个很重要的概念叫微服务。微服务就是应用的各项核心功能,而且这些服务均可独立运行。这个概念,应该跟早年的SOA
面向服务的架构概念关联,甚至一脉相承的。在SOA
年代,我们要求把所有的后台功能服务化,而所有的服务,都会发布到ESB
(企业服务总线)上。这些服务,可能分布在多个实际部署的系统上。
那个时候,系统的调用都是通过ESB
为中介的。容易导致ESB
本身成为瓶颈甚至故障点。
服务化
的关键则在于良好的接口契约定义(接口名称、入口、参数、响应)。而接口契约定义的难点在在于粒度
。所谓粒度
可以简单的认为是一个接口对应实现的功能多少。这个基本上很难有一个定论,大有大的好,小有小的好。只能根据业务具体分析了。但我推崇的方式是按专家模式
来划分。所谓专家
,就是掌握一定知识(数据)同时只做自己擅长(能做)的事情。当然,也有其他的比如代理的模式,但一般意义上,先把每个服务当做专家
总是错不了的。
至于服务的交互协议,我觉得基于HTTP
协议的RESTful + JSON
是最好的选择了。越重的模式,或者包装越多的,总是适应面受限。
不管如何,服务化这个概念,基本上大家都很认可和接受了。从SOA
转向微服务
,则是在物理实现上,分成更多的微服务系统
。也就是在部署上,拆分的更细。一个独立部署、运行的系统所包含的服务更少了。在讨论SOA
的时候,提供服务的系统都是“大”系统,一个系统包含很多的服务接口。但在讨论微服务
的时候,提供服务的系统都是“小”系统,系统数量更多,每个系统提供的服务更少。
微服务带来最大的好处是应对变动。在单一应用架构中,任何的变动都将导致整个平台的整体停机发布,而且一旦出问题,版本回退、下线等会造成业务的暂停。而微服务架构中,只需要升级对应的服务,无需整体停机。当然,微服务架构也带来了整体的开发和管理的难度加大。相对简单的小型应用系统,或者开发一个全新领域的应用系统,谨慎使用微服务架构。从单体应用开始,逐步分离可能是一个比较好的方式。毕竟,借助于服务注册/发现工具(如Spring Eureka),调用者并不需要事先知道服务提供方是如何部署的。
而且微服务架构,可以使得系统的每一个部分都可以独立、并行的开发、调试。当然,需要你有足够的人手和足够强大的管理能力。
怎么划分微服务呢?这可能是一个没有标准答案的。我只能自己的理解:
也有设计师把一些公共的模块作为独立的服务,其实问题也不大,Spring全家桶里面很多都可以认为是公共服务,但就一个要求,这个公共服务足够稳定,也足够抽象,可以复用。如果有一些业务变动,就得改好几个服务,就得不偿失了。
每一个微服务,都是一个独立的运行节点,都要考虑这个节点是否安全(网络、单点故障),数据存储(数据库集群),复杂的分布式事务如何实现等等一大堆的问题。
得益于容器技术的不断改进、发展成熟,在很短时间内,部署一套需要十几个甚至更多运行节点的应用平台已经非常方便。这里主要就是说docker
了。它让一套复杂的系统的运行管理变得很简单。
一种理想的状态,是各个服务之间是没有必然关系的,完成一个业务完全由一种叫服务编排的技术/工具来完成。
这种编排方式,事实上是一种纯粹只存在于理想中的状态。或者,用在文档里面,表示一个业务流程需要依赖那些基础服务是可以的。几乎任何系统,业务流程实现的复杂程度会远远超出最初的想想的。最初的架构设计,只会把最主干的流程描绘出来,再开发过程中,各种业务的分支、异常情况,都会被发现,这个流程就会面目全非。最后描绘出这个流程则包含分支、回退甚至循环等。而一个图灵完备的程序语言所包含的操作无非也就是顺序、分支、循环这些。
以前的ESB工具,真的提供这样类似工作流的可视化工具,让开发人员不用写代码,就把各种服务以各种形式(顺序、分支、回退等)组织在一起。但我们知道,集中统一的ESB本身就会变成整个系统的性能瓶颈(事实上也是的,我从事过核心项目的银行,ESB都会变成玻璃娃娃,以至于大量的复合型的服务在后台直接提供,ESB提供毫无价值的“穿透”处理——就是没用你也得来我这打卡),甚至整个系统的风险点。然后在这基础上,提出一大堆诸如“统一对账”、“统一冲正”之类的让我头大的需求。
曾经有个笑话,国内某大型股份制银行,使用全新的SOA架构开发实施Corebanking核心业务系统,花了10年没上线。大家花了无数时间在ESB上画流程,然后发现业务流水被拆碎了没法跟踪,就在ESB上扩展建立适应所有业务的流水表。听得我心惊胆战。
从更高的层面,架构师确实希望各种服务井然有序,不要乱七八糟的关联在一起。如果形成一种网状的调用关系,确实是很可怕的。
比如,用户支付订单的时候,订单服务调用库存服务扣减库存,然后,然后调用商品服务更新销量,调用客户模块获取客户地址,然后调用统一支付模块,支付模块调用积分支付,然后调用现金支付,最后回到订单服务调用积分模块给用户添加积分,等等。诚然,业务的具体路程,确实只有在实现这个业务的过程才是最清楚的。而且,除了正常情况往下调用之外,还需要处理异常,比如积分不足导致支付失败,需要回滚,或者其他的补充处理——跨系统的流程和事务控制。
我们知道,类似工作流引擎这样的服务其实是很“重”的,比较适合用于OA、MIS(比如信贷审批)这样的系统。这些业务的流程很复杂,多变,不但需要针对不同的情况设定不同的流程,而且流程还经常改变。使用工作流会非常方便。但这些系统的特点都是并不追求高并发和短响应时间。而银行交易系统、电商系统虽然流程也复杂,但是更加追求的是TPS。这种模式并不适用。
然而到现在还有一些厂商吹嘘自己的这类系统很厉害,无所不能还支持很高的并发和实现很短的响应时间。反正我是不信的。
一个好的设计,应该是体现服务分层了。
我们把服务分成两层:
经过这样的编排,每个服务都成为了自己领域的专家了。但有两个问题需要考虑的:
而且,分层的方式,也加大了微服务划分的难度。我们知道,划分微服务主要是为了隔离变更,而分层依赖关系的存在,使得某个微服务停止的业务影响面会扩大。这就取决于对业务的理解能力了。
服务之间的调用方式分为同步和异步两种。一般来说,客户发起一个情况,肯定是需要得到一个响应的,比如转账,用户发起之后,肯定希望立马得到结果转账是否成功。但有些请求,不需要,或者不能立马给出答案的,只能告诉用户:我收到了,等消息吧。
同步和异步的实现技术现在都很成熟。但我个人来说,对象化的RPC(Remote Procedure Call)
实现都不是好的实现。从最早的Corba
到EJB
我都认为不好。分布式架构必然会面临异构体系的,比如我个人比较习惯用JAVA,但也不排除我做的应用里面包含PHP、Python等开发的程序。包装的越多的协议,就越不稳定。当然,RPC体系中很重要的寻址路由这部分很复杂的东西,我们不深入讨论,毕竟对于大多数人来说,用好比如Eureka
之类的开源工具就很好了。但是,对于最终“调用”(获得服务地址之后)的那一下,HTTP/HTTPS + JSON
应该是可见的未来中最简单、最实用、适用面最关、包容性最好的方式了。简单就是最好的。
顺便提一句,基于HTTP请求的处理过程,我认为充分利用好HTTP Status code来表示错误是非常优雅的。比如,大家都知道200是成功,500是大家都害怕的内部错误,404找不到页面这些,基本上大家都很清楚。而且任何一个HTTP客户端处理响应的时候,都会为所有的错误状态触发错误事件。从错误的角度,不管是网络错误连接超时(当然这个时候没有状态码了)还是后台返回的错误,都是错误,都是需要错误处理的。从业务的角度,错误也是一种业务逻辑,而不仅仅是异常。而且,HTTP状态码的表示基本能覆盖错误类别,比如:
等等状态码,语义本身也是很清晰的。尤其采用RESTful风格定义WEB API的时候,本身也会经常出现只接受POST方法的用了GET方法请求之类的方法错误(405)。我的理解是,既然HTTP客户端无论如何都需要用ERROR(错误)函数去处理错误,为什么要在SUCUSS(成功)函数里面去处理错误呢?某厂封装的客户端工具就让我很无语,硬生生重新封装了一层,除了他自己认为的类似网络错误这样的,才会触发ERROR。在成功函数里面去处理错误。这回路我不能理解。
在分布式系统的实现中,也有人提出全面基于事件模型的机制,也就是全部都是异步调用,必须同步的场景,也是同步转异步,然后等待结果。很强大的理想。但我认为真没必要。同步和异步都有适合的场景,而且,同步调用本身更简单。异步的作用在于:
NIO
机制。异步机制的使用限制也很明显,主要就是不需要强一致性的场景了。如果需要保证一致性,或者从流程上必须得到响应然后执行下一步的,还是踏踏实实用同步调用吧。
如下图的例子:
这是一个电商系统的实现例子。实线是同步调用,虚线是异步调用/通知。
用户从购物车中选择商品进行结算到支付的过程。在提交订单的时候,已经确定了:
前面提到,采用服务分层的方式,业务流程在聚合服务中体现,原子服务不要交叉调用。所以,提交订单的请求,会进入“购物服务”,这是一个聚合服务。负责完成整个订单的生成处理。
创建订单的流程涉及如下核心服务:
按照该系统的设计,上述四个步骤在创建订单过程都是必须的,而且是有序的,并且是一个完整的“事务”——如果订单创建失败,需要释放库存和积分/优惠券。
从“购物服务”(聚合服务)到各原子服务之间都是同步调用(基于HTTP)。而原子服务之间没有直接的调用关系。
同步调用的最大问题在于超时。如果调用失败,不管是直接被拒绝,还是返回错误,都是明确的状态,可以采取相应的处理手段。但如果超时也就意味着处理状态不明确,就会导致数据不一致。超时一般的应对机制是反向操作。这在后面“数据一致性”中详细讨论。
各个服务处理完自己的事情之后,可以发布“事件”,比如:
异步消息或者叫事件,被消息创造者发出来之后,创造者本身可以不关心,或者说也不应该关心消息是否被处理以及处理结果。换句话来说,就算这个事件没人处理,也不应该影响业务。比如,库存紧张的事件,如果有模块处理了,采购负责人会第一时间拿到通知。但没有模块处理的话,采购负责人也可以自己到系统查看库存情况。
从技术的角度,可有如下的模型:
可以认为,每个服务模块,都应该具备两个入口:
消息服务需要单独部署,避免影响主要业务流程的性能。具体产品如Kafka等非常好。如果简单地使用,Redis也不错。
事件机制一般采用消息服务的发布订阅(PUB/SUB)模式实现。
在软件架构中,发布订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。
(来自百度百科发布订阅)
和通常的先进先出点对点的队列不一样,发布订阅通过“主题(Topic)”区分消息的类型。
在这个机制下可以认为,消息并不是由发布者直接送达订阅者。发送者只是发送了一个属于某个主题的消息,剩下的就不管了。这样提供了极大的松耦合实现可能——可以任意增加对特定主题事件处理的模块。
比如,“订单支付成功”这个事件可能有如下模块分别处理:
以上等等,增加模块,或者减少模块,对主流程毫无影响。当然,也有极端情况,可能消息没人处理。这就需要设计事件模型的时候,有一个整体的考虑了。
不同的订阅者做不同的事情,在这个场景是最合适不过的。但如果订阅者是一个集群,比如处理用户积分的订阅者——积分模块——有多个服务同时处理任务。这就带来一个问题:同一个事件会被集群中所有的服务都拿到。如果都去给客户增加积分,就出问题了。所以,订阅者的事件处理机制,很重要的一点就是实现幂等性。也就是同样的工作只能处理一次。
刚刚的例子,通知支付完成,需要给用户增加积分,只能增加一次。多个模块只能有一个处理,其他的不处理,或者说多次处理和一次处理的结果是一样的。
实现的办法可以通过消息发布者为每个消息附加一个nonce值。这个值一般只要保证短时间内唯一就行,比如发送者标记+时间+随机数+内存顺序计数,或者通过redis自增都行。收到消息订阅者,通过redis做一个锁,成功的处理任务,不成功的就放弃这个消息。这是一个比较简单的机制。
消息的另外一个问题就是时效性和补偿机制。由于发布者并不考虑是否有订阅者,所以会有一个极端的问题,就是某类消息确实没有订阅者。这些消息会被堆积在消息队列中。所以一般需要为消息设置一个有效时限,比如2个小时。另外,不管哪个消息服务的实现,其实都无法保证100%的消息不丢失,这也是一个现实。所以,也就意味着,如上面的例子,用户支付成功处理积分,可能这个消息遗失,会造成用户积分没有加上。
对于系统的实现来说,首先尽量保证所有的消息都被处理,但有一些消息如果没有处理,其实也问题不大——比如用户通知——就不管了。但另外一些,比如积分,必须处理的,就需要补偿机制了。比如在日终统一的批处理任务把漏算的积分都给补上。
插个题外话,一般日终批量处理,是几乎所有最终一致性的兜底机制。
总结一下消息/事件机制:
在分布式系统中,数据一致性是一个必须面对的问题,而且还是一个很严重的问题。不管强一致性还是最终一致性,都必须保证一致性,只是实现一致性的时机问题。真的不需要实现一致性,或者弱一致性的场景,其实并不多。
究竟哪些是强一致性呢?前面谈到过,如果只有一个数据库,强一致性是毫无问题的。为啥会有一致性问题呢?
ACID
不完整,导致不正确的数据出现。在分布式体系下的事务就是分布式事务了。不但涉及到事务保障/补偿机制,还涉及的应用实现的逻辑如何配合,比较复杂。消息在网络上传递不管同步还是异步,也不管采用哪种分布式事务的实现机制,也都应该有对应的补偿机制。后面专门讨论分布式事务。
这里主要考虑一下第一个问题。为什么要把同一份数据存在不同地方呢?最重要的原因一定是性能提升。把redis作为高速缓存,快速存取数据;数据库读写分离提升读取效率等。
不同地方的数据内容,严格意义上来说:
如果这三个地方数据严格一致,并且不牺牲可用性(响应时间不延长),那就什么问题都不会存在了。但这基本不可能存在。所以问题变成了哪些数据或者场景数据读取都必须只能在主数据库上,当然,还得加一个限制,这些数据变更发生之后很有可能立马有人访问的。如果这些数据很长事件都没人访问,其实也就无所谓了。
在银行系统中,账户表,包括账户的属性,限额,余额等,在交易场景中,有着非常强烈一致性需求。在手机银行把钱都转走,同时在ATM把钱取出来,如果账户余额不一致,银行就可能亏了。但在报表场景,账户数据完全可以慢慢的同步到另外一个数据库去。
我们可以把账户表这种表当做一类,比如叫I类,属于核心实体属性表,更新不但频繁,还必须必须保证原子性。这种数据就是强一致性要求的。最理想的办法就是,在哪里写的,就在哪里读——数据库单点的操作都能保证事务一致性。这部分我觉得其实真没有什么特别好的办法。几种有用,但都不优雅的方式:
分库和分片是目前比较常用的方法。分库还说好,每个服务自己访问自己的数据库,不会有太大的问题。但分片,意味着一个SQL到来的时候需要分析这个SQL应该在哪个Sharding库执行,甚至个别情况需要跨库执行。这和数据库内置的表分区很像——把一个表的数据按规则存放在不同的文件,找数据的时候只需要在一个文件找——但这是多个数据库实例组成的分区。这一般需要通过一个Proxy(代理模块)来处理。
如果不差钱,每一个单独的数据库都加上HA。基本完美了。
还是那句话,基于数据同步的强一致性保障,一定是有代价的。比如交易时间延长,牺牲可用性。
在电商系统中,类似需求的数据是库存,如果经常做限量促销,抢购的,就更需要严格保证库存数据的一致性。另外,如果建立了虚拟资产,比如积分,储值卡的,其实也就是一个账户系统了,跟银行账户一样对待。
第二类数据,我们叫II类,是交易流水。一般来说,每一个交易流水都是一个独立的实体,会尽量设计成为不可修改(银行交易流水的操作主要就是冲正,一般会新增一条记录表示冲正,而不去修改原来的记录)。也就是在系统中,这类数据就是飞快地不断地插入。这类数据不能丢,但基本也不会有访问冲突。我们知道,不管什么数据库,插入都是很快的。甚至我们可以采用多个数据库实例分别插入部分数据,最后合并。这种数据完全可以使用最终一致性实现。
第三类数据,我们叫III类,是登记簿。电商的订单表,其实是很典型的登记簿,插入一个新的订单之后,随着业务变化,需要更新订单的状态。最基本的流程是系统创建订单记录,然后提交支付,支付通知回来后更新订单的支付状态,前端发起查询订单状态。实际上就是一个时序操作的问题:
我们试想数据库读写分离的情况,创建和修改没问题,但在从库查询可能会有延时,第一时间查询不到,告诉用户再查一下,应该关系不大。其实银行系统里面也有大量的登记簿,比如中间业务的缴费登记簿,理财的销售登记簿等。
再考虑用Redis缓存集群来保存数据的情况,Redis集群中的主从也会有数据不一致/延迟的情况,所以同样的问题就是用户查询的时候,有可能查询不到,需要重试。当然,这里的问题是,Redis宕机了这个订单可能丢失。所以这类数据不适合直接记录在Redis,还是应该记录在数据库,但读取订单的时候,可以把订单数据缓存到Redis,提高访问效率。只要解决订单变更同步问题。
第四类数据,我们叫IV类,是相对稳定的数据,比如产品信息、客户信息等,变更比较少,但经常被读。这类数据基本肯定不要强一致性。我的建议就是放到Redis缓存起来,有修改就同步。
第五类数据,我们叫V类,是易变、可损失的数据,比如阅读量,埋点跟踪等。变更特别频繁,但丢了一些其实影响不大。其实就是可以一定程度放弃一致性的体现了。
总结一下,对于强一致性需求的数据没什么特别优雅便捷的方式,因此,最重要的是在设计过程识别出那些数据必须强一致性处理。这个和设计数据模型的时候识别实体一样重要,但得在此基础上考虑业务流程的需要才能识别。
最终一致性的内在含义有两个:
所以,此类数据必须首先保存在持久化存储的数据库中。然后通过一些列机制同步给别的服务。至于同步的延时容忍,取决于业务需求。比如早年间的信用卡,压卡消费之后,可能需要好多天才会同步到银行。又如早年的银行积分,很多银行都是每天晚上才算出来给你增加一天消费所得的积分。当然,随着技术发展,这种延时的变得越来越短,现在可能你刚消费几分钟之后,积分就更新了。毕竟竞争这么激烈,尽快能把收益反馈到客户手中,能提升客户服务水平。
前面也提到了,在交易层面最终一致性的实现首先是基于消息机制或者事件模型。同时提供事件消息的补偿机制,这样可以不依赖于消息服务的可靠性——当然,消息服务的可靠性越高越好。由于可以通过事后补偿,所以,即便消息发布失败,也没关系。
这种方式也叫“最大努力通知”。前面已经介绍很多基于发布订阅(PUB/SUB)模式的异步消息机制的实现方式了。这里主要从数据一致性的角度考虑。最终一致性不等于不需要一致,最后必须保证一致的。但任何系统操作都存在失败的可能,比如发布消息就失败了,所以补偿机制很重要。一般补偿机制有几种:
第三方支付的模式,是一个很好的例子:
由于第三方支付真正的支付交易本身是在第三方支付客户端内部实现的,并不经过商家的服务端,因此,需要等待第三方支付系统通知支付的结果。而如果等不到结果,我们就会主动去查询这笔支付的结果。而如果查不到,一般会认为支付失败了。但实际上,并不一定真的就是失败。事后如果查询到支付确实是成功的话,或者在对账的时候发现状态不一致了,就需要给客户退款了。
对于电商系统内部来说,包括为客户累计积分、为分销商计算提成、新用户注册通知、给客户发通知等,都可以采用这种方式。
前面说这么多是通过消息服务把信息传递给别的服务,并期待别的服务能实现最终一致性。
而另一种需要考虑的场景是数据同步,比如数据库的主从同步(读写分离)和数据库与高速缓存的同步。主要体现在如何高效的获取数据,而读到的数据可能是脏数据,如何处理或者补偿脏数据带来的问题。主要的补偿有两种可能:
这里先说一下数据库同步。一般数据库的数据同步分三种模式:
同步复制方式这里不讨论,我认为实用性不大,前面“强一致性”的已经讨论过。如果能容忍写入性能稍微下降一点(配置更好的数据库服务器,万兆网络会好点),半同步是挺理想的模式,毕竟一个系统中查询数据库的压力非常大,虽然在DAO层想尽办法做缓存了,压力还是很大的。
如果考虑写节点的单机故障问题。需要把上面的部署修改为:
双主多从的结构,主要要点是:
根据数据库同步的特点,结合前面的例子,我们可以把订单,评论等内容在写节点写入,在读节点读出,如果读不到也不要紧,反正早晚能读到的,一般再刷新一下,就出来了。
数据同步的另外一个场景是数据库和高速缓存如Redis之间的同步。尤其对于频繁读取,修改又少的数据来说特别适合放到Redis提升读取的效率。当然,内存要大,服务器成本上去了。对于缓存而言,也有两个要求:
一个常规的做法是:
变更的同步也有两种做法:
当然很多DAO层的开源实现比如Mybatics对于缓存的支持都挺好的。
不要一致性的数据虽然少,但还是有的。为什么要放弃呢,主要就是性能问题。这种数据变更非常频繁,比核心业务频繁的多,同时又不影响业务。一个典型的例子就是商品的阅读量,文章阅读量,埋点跟踪等。这些数据很有用,能帮助更好的改进业务。但这些数据又不是直接影响交易的。也就是,精确度不需要那么高。
对于阅读量这种指标的处理,会采取一个二段模式:
一般情况,读取指标也是直接读Redis,不会有什么问题。问题在于如果Redis宕机了,这几分钟内的点击量就有可能丢失了。我们不会为了这些可能丢失的数据采取更多的手段。
而类似埋点这样的数据,一般处理比较复杂,很多人会选择用给第三方的埋点服务,把压力给他们。但如果非要自己做,这种数据直接就发送给专门处理埋点数据的服务器去,不要影响主服务的性能。而如果埋点服务的处理机制一般也是,尽量去处理所有的数据,但如果有数据丢失了就丢失吧。
微服务体系由多个独立的服务组成完整的业务流流程,为了实现数据一致性,往往还需要对流程中的事务,操作顺序等进行协调。
数据一致性往往跟事务关联在一起。再原来的单体应用中,数据库的事务基本可以解决所有的问题。而在分布式体系中,一个业务流程的处理,涉及多个独立的服务以及多个数据库。就无法依赖数据库的事务来保证“一致性”了。但从逻辑上,事务的“原子性”还是需要保证的——数据要不全部更新,要不全部不更新。
所以,简单的来说,分布式事务就是在分布式体系下实现事务,把数据都正确记录到各自的数据库。分布式事务有很多种实现方式,包括:
两阶段提交(2PC)我相信大家都不太会用,对数据库依赖太强,而且牺牲可用性。TCC和SAGA看起来有点像,但认真分析的话,TCC其实挺难实现的,对代码改造、甚至数据模型都有侵入的要求。用一个转账的例子(A给B转账100元)来对比:
要说明一下,只是一个好说明的例子。银行系统不这样干。可以看出,TCC真的很复杂。其实同样是把事务的逻辑交给了业务本身,SAGA相对简单多了。当然,TCC的好处是,事务处理的过程中,如果有别的交易来查询A的账户,值还是交易之前的,只有事务成功了,才会改变。SAGA模式则在交易过程中值就改变了,如果失败,再变回来。实际上,大部分的银行跨行转账,都是类似的实现。毕竟,你发起的交易要转出去,我就给转了,余额就减少了,有问题,给你回退回来,也很合理。
这也是一个简单的示意图而已。准确的来说,为了保障交易成功率,第一步转账一般都是转入一个肯定没问题的内部账户的。晚上批量在入账清算。
每一个步骤都是独立的事务,但如果最后一步失败了额,对之前的操作步骤调用对应的撤销服务。这里需要强调一点,如果当前操作步骤是超时,前面提到过,也就无法确定交易的状态了。这时候对当前操作,也必须调用反交易。
其实冲正交易在银行系统中是非常重要的,几乎所有账户操作都有对应的冲正操作。尤其是操作超时的情况,基本上跟着下一个交易就是对上一笔交易的冲正。
从这里可以看出,SAGA模式如果事务嵌套很多层,可能就是一个悲剧了。而且,还有一些约束:
需要注意的是,反交易并不能消除正交易的操作痕迹。毕竟并不是数据库的rollback。只是删除了这条交易记录,或者给这个交易记录增加一个作废/冲正状态,更多的是增加一条新记录,表示之前某一条记录作废。
由于需要保证反交易交易一定要成功,所以,系统往往会采用多次重试的机制(做一个冲正队列表,定时执行)。因此,反交易必须保证幂等性。一个比较好的事件规范是交易的最初发起者应该给一个唯一的标识。反交易只发送这个标识。就算多次发送同样的交易,也能保证同样的效果。
另外,可能多次尝试,依然会失败的情况,就只能留到日终的时候对账了,甚至,人工介入调整了。
我认为,对于长事务的支持来说,这是很好的机制。
关于分布式事务是否应该基于消息机制/事件模型。有两点说明:
总而言之,利用异步来实现同步的场景,很麻烦。如果团队技术能力很强,可以尝试。毕竟,分布式事务就不是一个简单的事情。
和多线程环境一样,多个线程的工作可能互斥,就需要“锁”。得到“锁”的线程才可以干活(进入某段代码),没有得到“锁”的线程也要做这个事情(进入同一段代码)就只能等待,等拥有“锁”的线程把“锁”释放出来,然后,抢到这把“锁”。
多线程都在同一个进程里面进行,所以进程内就是实现统一协调。而分布式环境中,多个服务的协调也需要一个“上帝”来仲裁。一般可以用:
使用Redis做分布式锁比较简单,setnx一个key,如果成功就是获得了锁了。如果失败,就没有获得锁。得到锁的服务,完成任务之后,删除这个key就可。但有几个事情需要考虑:
而使用ZooKeeper实现分布式锁的最大好处是可以通过Watch机制,锁被释放的时候所有等待锁的服务都能得到通知,被唤醒继续抢锁。基本的方法是在一个普通节点下创建临时节点,比如在Lock下创建临时节点,创建成功表示获得锁。释放锁的时候,删除此临时节点,每个服务都得到通知。而且,当有锁的服务宕机,锁会自动释放。
分布式锁本来是一个很难实现的技术,感谢各种大拿提供各种开源组件,让这种事情变成简单的,用工具。作为并发的协调机制,并发锁本身是一个很重要的技术,也是一种设计的方法。还是需要深入理解其应用场景的。
比如两个处理手续费的服务,都要扣除同一个账户1%的手续费,这种场景,就不能留到两个服务去争抢数据库锁去处理,而是在之前就决定,只让一个服务去处理这个任务。在前面异步消息机制的时候已经提到过了如何防止重复操作,也就是实现操作幂等性。
在电商系统,分布式锁最典型的应用是在库存。前面说到,库存是需要强一致性的,同时也提到了在数据库层面如何保证库存数据的一致性。某个用户扣减库存之后,再有用户来访问到的数据一定是扣减之后的。但库存服务本身也可能是集群,多个服务同时处理用户购买同一个商品,尤其在秒杀场景。这时候,对库存的操作包括:
这三个动作是要在一个原子操作里面完成的。否则,这服务刚查询的库存是10,以为满足了,结果还没扣减就被其他进程给扣减了,就形成超卖了。
要实现这个需求的原子性,一般轻易不要使用数据库的select for update
来加锁,不但严重依赖于数据库,而且很容易变成全表被锁,也就是所有商品的扣减库存都串行了。这个性能就严重下降了。
我们可以用Redis的分布式锁,针对商品编号上锁,然后查询、比较、扣减,然后释放锁,唤醒其他等待服务。这样,就仅仅串行了对一个商品的扣减库存动作。如果这原子动作需要10毫秒完成,那么每秒钟最多就处理100个订单请求,而不管有多少个服务做集群了。对于一般的电商系统而言是足够了。但如果需要更多的,比如十倍、百倍以上的并发,其实就不太好处理了。就算加强数据库查询和更改的效率,减少到每次处理1毫秒,提升也是有限的,但这确实也是首要的办法。然后就是把库存分开了,一个商品分成10个库存,也就是10个独立的库存服务,也就是分成了10个锁了,基本上提升10倍。
最后呢,可以看出,锁能有效的保持服务的执行顺序,也就能保证数据的可靠性和一致性。但是锁其实是影响并发性能的,用了锁,意味着操作串行,分布式的优势就会损失。在能保证数据一致性的前提,能不用就不用的。
写了这么多,也算是自己对分布式架构的理解。总结一下,我认为开发软件中比较重要几个事情:
关联文章:
软件开发随笔系列二——关于架构和模型