软件开发随笔系列一——分布式架构实现

软件开发随笔系列一——分布式架构实现

文章目录

  • 软件开发随笔系列一——分布式架构实现
    • 理论基础
    • 分布式架构的实现
      • 内核框架
      • 应用开发
      • 基础设施
      • 服务接入
      • 监控
        • 日志监控
        • 调用链监控
        • 度量指标监控
        • 健康检查
        • 警告和通知
    • 微服务
      • 服务的编排
      • 同步和异步
    • 数据一致性
      • 强一致性
      • 最终一致性
      • 可放弃的一致性
    • 分布式协调
      • 分布式事务
      • 分布式锁
    • 总结

从二十多年前写第一行BASIC程序到现在,对于软件开发技术不断发展,最大一个感受就是,各种可复用的开源组件越发丰富,很多以前自己想做但没做,或者做不出来的,甚至没想到的,都有了,而且都做的很好。以至于到了现在,有些人认为没用过或者不会用某些开源组件,就是不懂技术,或者不懂架构了。诚然,熟悉众多开源组件,用搭积木的方式快速构建软件的技术原型会节省很多时间。但软件并不是靠搭积木就能搭出来的。

为什么直接谈分布式架构呢?其实好多年前的应用软件基本就是分布式的,从早期的C/S,到B/S模式,到多层架构,微服务架构,组成一个应用平台,需要独立运行的系统越来越多,比如WEB服务器,各种中间件、业务系统、数据库、缓存以及若干辅助系统,比如监控,统计等。比如银行从手机银行完成一笔转账,可能需要经过:

  • 手机银行前端APP。
  • 手机银行服务系统。
  • 银行前置系统。
  • 银行核心系统。

如果是跨行汇款还有可能需要经过人行现代化支付系统。这是是简单的示意,实际任何一个银行系统环境都只会比上述更加复杂。

如此复杂的系统环境,会对系统的数据一致性、可用性都提出了很高的要求,同时对实现交易高并发、低延时难度增加。尤其在银行的系统,基本上都是账务交易,都要求数据的强一致性,对于高并发的要求则难度更高了。

理论基础

谈分布式架构,不得不提几个广为人知的理论:

  • ACID 事务四大特性
    关系型数据库具有解决复杂事务场景的能力,关系型数据库的事务满足 ACID 的特性。
    • Atomicity:原子性,要么都做,要么都不做。
    • Consistency:一致性,数据库只有一个状态,不存在未确定状态。
    • Isolation:隔离性,事务之间互不干扰。
    • Durability: 永久性,事务一旦提交,数据库记录永久不变。
  • CAP 在一个分布式系统下,包含三个要素:Consistency(一致性)、Availability(可用性)、Partition tolerance(分区容错性),并且三者不可得兼。
    • ©Consistency-一致性:所有节点在同一时间返回相同的数据(数据保持一致)。
    • (A)Availability-可用性:保证对每个客户端请求无论成功与否并在合理时间内都有响应(好的响应性能)。
    • §Partition Tolerance -分区容忍性:系统中任意信息的丢失或失败不会影响系统的继续运行(可靠性)。
  • BASE BASE 理论主要是解决CAP理论中分布式系统的可用性和一致性不可兼得的问题。BASE 理论包含以下三个要素:
    • BA:Basically Available,基本可用。
    • S:Soft State,软状态,状态可以有一段时间不同步。
    • E:Eventually Consistent,最终一致,最终数据是一致的就可以了,而不是时时保持强一致。

我们都知道,传统的关系数据比如Oracle都是支持事务的,事务最重要的特性就是ACID,也就是保证了强一致性。不管何时,只要对事务执行了提交(COMMIT)操作之后,别的进程/线程读到的数据一定是更新后的。如果执行回滚(ROLLBACK)操作,则事务中操作的所有改变都放弃,恢复到原来的状态。

软件开发随笔系列一——分布式架构实现_第1张图片

依据CAP理论,三者不可兼得,最多只能同时满足两个特性。传统关系数据库在单点保证了C(强一致性)和A(可用性)。对于数据库的集群,在CAP理论广泛流行起来之前,数据库厂商似乎努力让我们相信数据库是无所不能的,比如通过昂贵的存储设备共享磁盘的方式实现集群。但现在我们知道了,如果存在分区P,而且要容忍分区,又要保证强一致性C,那么就只能放弃A(可用性)了。这就意味着响应时间可能会无限延迟。

因此,对于银行核心系统的数据库来说,既要必须保证数据强一致性,又需要保证高可用,核心数据库往往会选择部署在昂贵的高性能小型机上,采用主从高可用(HA)方案。也就是同一时间只有一个服务器拥有负载,另外一台闲置。负载的节点一旦出现问题,迅速切换到闲置节点上。以此消除单点故障。

开源数据库在允许响应时间在可控范围的延长前提下,采用同步复制到集群中的多个节点的方式,只有全部节点同步完毕,事务才完成。或者在响应时间和一致性中相对灵活的控制,采用异步方式进行多节点数据同步,用以实现:

  • 更新数据效率不下降,主节点(写入节点)实现强一致性,如果不想读到脏数据的场景,直接在主节点读数据。
  • 分散读取数据的压力,对于允许一定程度数据延时同步的场景,可以都在从节点(读取节点)获取数据,分散负载。

这叫读写分离。如此,部署再多的从节点,也不会影响数据更新的效率。

这里提到允许一定程度数据延时同步,这恰好是BASE理论的基础了。是不是所有的数据更新都一样要立马体现出来呢?身边同事多是从事银行系统开发的,对他们来说,可能觉得大部分重要的交易都是的,比如转账,取款,存款,消费等。毕竟,在多渠道服务已成常态的时代,如果消费金额不能实时体现在用户的账户中,很有可能造成银行的损失。但是对于更广泛的场景而言,其实真不是太多交易有强一致性要求的。所以,有人问我如何提高系统性能的时候,我一般第一个回答是让他们先把交易理清楚,先找出有强一致性要求的数据出来。我把这叫强弱分离。强一致性部分没什么太好的办法,但弱一致性要求的部分,可采取的方法就多了,最基本的,通过消息机制异步化处理过程。这就是Eventually Consistent——最终一致性。就是可以过一会儿,数据才是一致的。这之前,有脏数据,可能没人在意,或者压根没人用。这里就有很多例子了,比如在电商系统里面:

  • 某种操作之后为客户添加积分(使用积分需要实时划扣,而给积分,其实不着急);
  • 商品的评价并不需要别的用户立马就看到;
  • 给用户发通知,晚一会儿也没关系。

等等例子。只要记住一点,最终一致性,并不是放弃一致性,必须建立最终实现一致性的机制。

另外,确实有一些数据,可以容忍某种程度的丢失,比如阅读量,这样的数据更可以一定程度放弃一致性已达到更高的可用性了。

分布式系统中,由于总会面对分区(P)的可能性。所以,生成的一致性(C)、可用性(A)就只能选择一个了。PC的组合往往会导致系统响应时间变长,以至于不可用。因此,PA+最终一致性成为分布式系统最常用的选择了,基本就是被表述为BASE了,与ACID站在了两个极端。

软件开发随笔系列一——分布式架构实现_第2张图片

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.

这里提到了三个步骤应对分区出现:

  1. 探知分区发生
  2. 进入显式的分区模式以限制某些操作
  3. 启动恢复过程以恢复数据一致性并补偿分区期间发生的错误。

比如探知到网络中断的时候的选择:

  • 放弃操作,损失可用性;一般情况都会反复尝试然后放弃操作;
  • 继续操作,冒险损失一致性。

但往往有一种可怕的情况,就是网络中断并不是在交易发出之前,而是过程中。可能无从判断交易的实际状态。在网络恢复之后,往往采用反交易的方式,取消之前的操作。这其实也是放弃了强一致性了。在银行系统中,这种做法是常用的。文中提到的ATM取款限额的方式,低于限额可以先给钱的情况,在国内基本是不可能采用的。

无论如何,文章给了我们很多提示,尤其在如何应对分区出现。由此也出现了一些所谓“金融级”的分布式强一致性数据库。但我认为,这完全就是用户对于产生数据不一致的风险的接受程度了。

分布式架构的实现

讨论技术实现的时候,最近几年总会有人提到“互联网技术”,把一大堆开源组件、工具放到一起论证、比较好坏。其实挺没意思的。工具确实有好坏,但坏的东西,基本上也流行不起来。功能差不多的工具也具有不同的特点,设计上也有所区别和侧重,但基本上都可以满足大部分的需求,而且大部分这类工具的文档都挺好的,很容易找到区别。虽然选择一个合适的工具很重要,但千万别把架构师的工作就放在堆积木上。

实现一个分布式系统,有几个层面的工具/技术很重要:

  • 数据传递与存储:包括数据库,缓存,消息队列等。
  • Spring:Spring确实是一个很伟大框架技术,貌似现在JAVA后台开发都被Spring一统天下了,尤其现在的Spring Cloud,除了组件框架之后,还提供了很多可用的组件。
  • 监控:监控是非常重要的,对于系统状态、交易情况、用户使用等。

当然,还有很多很重要的技术点或者设计/架构模式,比如微服务,基于微服务的数据一致性保障等。

一个可能的分布式架构的实现如下图:

软件开发随笔系列一——分布式架构实现_第3张图片

显然,这个架构是适合交易系统(OLTP),比如电商,手机银行等各类交易服务系统。不适用于数据处理或分析系统(OLAP)。其实这里叫架构,也不一定合适,更像一个脚手架,用各种工具先把一个系统的外围支撑起来,至于业务的实现,则会体现在具体的服务的开发上,这部分是硬功夫(编码)。

这个框架可以分为几个部分:

内核框架

其内核部分,也就是业务逻辑实现的框架依赖Spring Cloud搭建。确实是搭建,主要用到几个组件:

  • Spring Cloud Gateway:网关服务,主要对API(服务)进行路由,提供强大的过滤器功能,包括头部过滤器、路径过滤器、Hystrix 过滤器 、请求URL变更过滤器,还有参数和状态码等其他类型的过滤器,可以实现熔断、限流、重试等实用功能。
  • Netflix Eureka:现在Spring把Netflix提供的几个组件,比如Eureka,Hystrix,Ribbon等都归到Spring Cloud Netflix里面了。Eureka是一个基于REST的服务发现工具,用于定位服务提供者,以及故障转移。
  • Netflix Hystrix:熔断器,容错管理工具,旨在通过熔断机制控制服务之间的联系,从而对延迟和故障提供更强大的容错能力。Gateway已经集成了Hystrix,但很可能在后面的服务实现中仍然需要熔断。
  • Netflix Turbine:对Hystrix熔断事件的聚合。Hystrix带有Dashboard可视化检查服务熔断情况,但如果相同的服务部署很多副本的时候,希望可以把相同服务的节点状态以一个整体集群的形式展现出来。此时使用Turbine进行聚合。
  • Netflix Ribbon:客户端模式的负载均衡,配合Eureka,从其获取服务的提供方,然后按照某种策略去均衡负载,如round robin、随机Random、根据响应时间加权等。
  • Spring Cloud Config:集中配置管理工具,把每个节点所需要的配置文件内容放到远程服务器进行集中管理。
  • Spring Cloud Security:提供OAuth2.0认证授权,实现单点登录、令牌中继、令牌交换等功能。

诚然,有很多可替换的部分,比如网关使用Netflix Zuul,集中配置使用Apollo等。但可以看出,几乎除了自己的应用逻辑之外,一个软件平台应该有的外围功能都有了。

Spring Cloud本身也提供了更多的工具包/组件,而且在快速迭代发展。基本上,会比我们开发应用还快。只能说随着时间的推移,选择最合适自己的组件了。

应用开发

应用开发的框架,在JAVA的世界,毫无疑问,SpringBoot是不二选择了。不但可以快速构建项目,自带运行中间件环境,如Tomcat、Jetty等,尤其是应用部署、运行非常方便。

尤其对于微服务架构的模式,每个运行的服务,都应该是轻量级,容易部署,容易监控,甚至容易停止/替换。可能有人也会说Spring Boot各种不好,配置不清晰等。但如果原来一直需要把JAVA程序费劲的部署在中间件过来的人,真是觉得神清气爽的。

应用逻辑的开发,基本都在每一个微服务里面了,比如电商系统的商品模块、支付模块、订单模块、促销模块、内部账户模块、客服模块等。当然,需要具体拆分成多少个微服务,就看自己的设计了。但在一个服务里面,遵循最基本的Controller+Service+Dao三层的划分模式,基本可以满足大部分——我认为只要数据模型设计的好的话。

  • Controller:功能入口并管理服务的调度、跳转。
  • Service:负责处理具体业务功能。
  • Dao:完成和数据库的交互——增删改查。

数据访问方面,我个人比较传统,喜欢偏向SQL的工具,比如MyBatis,很多操作配合一下SQL轻松搞定,执行效率还高。当然我不是抨击ORMapping类的组件不好,比如hibernate,也很好。甚至现在可能很多年轻的程序员写不出高效的SQL,还不如生成的。总而言之,我对数据层的理解就是:

  1. 数据模型一定要设计好。
  2. DAO层的代码要能自动生成。

关于服务内部的开发,还有最后一点就是日志。后面会提到ELK日志监控,但首先,是要记录好日志。一个典型的微服务可以看做是一个没有界面的服务系统,响应一定的请求、对请求做内部处理、操作数据库、调用其他服务四个部分组成。
软件开发随笔系列一——分布式架构实现_第4张图片

比如在银行体系中,卡系统一般都是独立部署的,对外提供基于卡的转账、取款、存款、消费等交易。如转账,内部处理获得对应的账号,并记录交易流水之后,调用核心系统执行转账交易。而在电商的支付处理中,内部处理记录支付日志,而后调用第三方支付接口。

当然,在好的服务设计中,应该尽量减少服务之间的来回依赖,这个后面再说。

记录日志我的一般原则是:

  • 所有外部边界的操作,都应有日志,包括:
    • 被调用的接口已经调用的参数以及返回的响应;
    • 调用其他服务的接口以及请求参数和得到的响应;
    • 操作数据库的SQL语句。
  • 内部处理过程中必须处理的异常。

我们知道,系统上线之后,绝大部分的BUG都应该被排除了。可能出现的问题,一般是是如下几种:

  1. 请求参数带来不是预期的值,导致无法处理。
  2. 第三方服务契约修改了,或者返回数据中有我们没有覆盖的分支。
  3. 调用数据库对应的值不对。如果对应的是表结构的问题,一般都测试阶段发现了,但有些值可能是请求参数来的,并不是我们期待的。
  4. 资源问题,比如网络中断,内存溢出,文件句柄过多等。

因此,发生异常需要记录日志,一定要把异常放生的上下文(请求参数,正在处理的对象等)记录下来。否则,看到异常,也不知道为什么发生异常。

当然,也需要灵活处理,比如一个接口返回一个表格的数据,就别记录了,开发过程多测试几次。那种每几秒钟检查一下某种状态的日志,是很讨厌的,最好用监控来处理,不要记在日志了。

对于这种后台服务系统,开发人员最好习惯于不要依赖单步调试,而应该熟悉的看自己的日志来进行调试。且不说很多多线程并发引起的异常单步调试的时候没办法发现,更要想到,如果一个系统上线了,被报告发生错误,你能拿到的只有日志文件,几乎没有机会、或者非常困难重现问题的。尤其不具备准生产仿真环境的场景,几乎只能抓瞎。

基础设施

对于分布式系统而言,最基础的组件(不包含硬件、操作系统等)应该有:

  • 数据库:指持久化存储数据的设施,包括但不限于关系数据库、图数据库,数据分析用的HBase、HIVE等。对于交易系统的核心业务数据存储而言,我还是倾向有严格模式Scheme定义的关系数据库,清晰的E-R模型能让系统可管理性提升。虽然有人说用无Scheme的数据库(比如MongoDB)更加方便修改结构。但我认为一个好的设计开发体系,研究是否修改数据结构的时间要远大于真正去修改结构。
  • 缓存:高速缓存,尤其是分布式的高速缓存对于现代分布式系统设计几乎是不可或缺的了。最常用的如Redis,功能强大、高效,而且稳定。尤其对于没有强一致性要求的数据来说,通过写入Redis暂存、快速获取,能极大减少数据库操作次数。而除了缓存之外,还可用于共享会话/分布式锁/自增序号/计数器,以及简单的队列、消息发布。
  • 消息队列:要极大提升系统的TPS(或者叫系统容量,并不是响应时间的缩短,而是同一时间能容纳的请求数量)除了强弱分离之外,更要在此基础上采用基于消息机制的异步处理体系。或者更进一步,建立事件模型,通过发布订阅(pub/sub)来传递事件消息。消息队列有很多实现,比如ActiveMQ、RabbitMQ、Kafka等。都是很成熟的平台,对于一般的应用而言,其实都可以满足。如果考虑巨大的吞吐量(甚至考虑用于准实时的数据处理)和消息的可靠性,就用Kafka吧。

这些基础设施是如此重要,以至于几乎所有分布式系统都会使用到。而且,不仅仅是一个独立安装部署的工具(当然,在工具层面也很重要,必须至少能消除单点故障并保证信息的安全),而且对于整个系统的设计模式都有很大的影响。用什么类型的数据库,用或者不用缓存、队列都是直接影响系统设计的核心点。甚至包括缓存和队列如何用、用在哪里,都是严重的架构决策。

服务接入

服务接入其实就是HTTP请求的接入大门了。目前绝大部分应用系统都是基于HTTP/HTTPS协议提供服务了。别的协议接口基本也不想讨论了。HTTPS+RESTful+JSON可以说是继SOAP之后一个没有啥创新但却很了不起,迅速为大家所接受并推崇的接口方式了。

这里异步抗高并发的Nginx貌似是唯一的选择了。尤其在作为静态资源处理、反向代理、负载均衡方面,Nginx都很强大,性能很高,而且很稳定,基本上不会挂。

当然有人追求更高稳定性,用Apache也是不错。毕竟,更加成熟的产品,传统WEB服务中依然是主流。

作为服务接入的统一入口,要注意的是这部分很可能是一个单点故障的存在。如果有条件,前面用如F5这样的硬负载均衡器就更好了。如果没有,用两个Nginx+Keepalived做HA方案是必要的。

软件开发随笔系列一——分布式架构实现_第5张图片

充分利用互联网资源,只把必要的HTML文件放在Nginx服务器上,JS,CSS以及图片视频等都放到CDN、OSS或者其他便宜的托管服务上。降低主服务的处理压力和带宽压力。

监控

监控对于一个系统的正常运行来说是非常重要的。可以及时知道系统的各种状态,如CPU、内存、网络的使用状态,以及各类请求处理情况,失败率,处理时长的。可能还有一些业务相关的状态、统计等。要很好的显示各种监控其实是挺费劲的。所幸现在有各类成熟的工具,直接部署起来就可以用了。当然,需要展现的数据还是需要在应用系统中提取出来。

一般监控有几类:

  • 日志监控:著名的ELK(Elasticsearch+Logstash+Kibana),即全文所搜引擎、收集转换日志和可视化平台。按照日志流转顺序,其实应该是L–>E–>K。
  • 调用链监控:在分布式结构下,尤其微服务体系,一个请求的处理需要经过多个服务节点处理,形成了一个调用链。这个链条中任意一个环节出现问题或者延时,都可能对响应结果造成异常。所以对链条的监控显得很重要。
  • 度量指标监控:对各种性能指标的监控,比如服务的调用次数,TPS,处理时长,以及JVM本身的状态指标。
  • 健康检查:对服务健康状态的检查。

软件开发随笔系列一——分布式架构实现_第6张图片

监控除了应该有可视化界面之外,应该可以确定规则,根据规则通知管理员或者相关用户。比如通过短信、微信、邮件等。

监控的开源产品很多,各有侧重点。但总体而言,监控本身是一个很“重”的服务,毕竟,一个运行的业务会有大量的交易,同时产生大量的日志文件。监控系统需要对这些数据进行采集、存储、分析并展现。但无论如何,为了业务的健康发展,还是需要建立一套合适的监控体系的——无度量,无改进。

这里引用一张图来说明各类监控的关系:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mb00Z2AN-1583058823139)(https://peter.bourgon.org/img/instrumentation/02.png)]
图片出自:Metrics, tracing, and logging

各类软件其实并不严格区分上述几类监控,往往功能会有重合。

日志监控

日志监控是最基础的监控,开发人员会在程序中输出大量的信息,尤其是错误信息,一般情况开发人员并不会刻意把错误信息发送到一个可以被直观的看到的地方。前文已经提到了一些记录日志的实践指导。在完善的日志基础上,日志监控就可以起到很好的作用,包括:

  • 发生操作异常的时候,不需要还原操作也能定位错误,并且知道如何解决错误。
  • 一些单步执行或者测试的时候难以发现的问题,比如并发上来之后出现的共享冲突问题。
  • 测试没有覆盖到的问题。程序复杂了,总会产生一些不可预知的行为,用户多了,这些行为可能被触犯,引发异常。

一个好的日志监控工具,确实能起到很大的作用。一般日志有那么几类:

  • WEB服务器(Nginx、Tomcat等)的accesslog(访问日志)或者errorlog(错误日志),基于WEB的服务必不可少的日志,格式很清晰,所有请求都能记录。
  • 应用日志:一般程序员基于LOG4J之类的日志组件输出到文件的,基本上没啥固定格式,但能最能反映程序的所有异常(异常堆栈,异常代码)。
  • 系统日志:一些必须的基础工具的日志,比如数据库等工具的日志。

日志监控这个,ELK特别成熟,基本上不需要犹豫。其中必须用到的Elasticsearch以及传送日志扩展使用的Kafka或者Redis,都是分布式架构中最常用组件,基本可以作为一个公共的基础组件对待。ELK实际是三个工具组成:

  • Logstash:实际上是一个内容转存系统,通过多种插件从几乎任意地方读取数据(这里主要是文件),然后转换、过滤,最后输出到想要的地方(Input --> Filter --> Output)。比如这里可以通过管道(Kafka等)最终送给全文检索系统Elasticsearch。这里的过滤很重要,因为日志文件可能会非常大,先行过滤处理是很有必要的。
  • Elasticsearch:出名的全文检索工具,在这里承担数据的存储、索引和检索功能。
  • Kibana:是为 Elasticsearch设计的开源分析和可视化平台。你可以使用 Kibana 来搜索、查看存储在 Elasticsearch 索引中的数据并与之交互。

日志监控的工具本身比较成熟,主要还是在应用日志方面确定一个日志的规范,便于解析。

调用链监控

调用链在微服务体系下是一个很重要也很有用的东西。不但在发生错误的时候知道链条中那个环节出错,还可以让开发人员全面的了解每一个业务处理的全面流程。毕竟,几乎所有项目一开始设计的时候都会有清晰的架构图、流程图,很清楚的描述每个流程都需要经过哪些环节,但时间长了,经过几个人的维护、升级之后,可能就不可控了。虽然我们都强调文档,但都知道文档基本不可靠,尤其哪个项目都是时间紧、人手缺的状态下。实际经验告诉我,很多对外宣称很完善的开发管理体系,进去之后,你会发现几乎没人说得清楚一个具体的业务流程实现都需要经过哪些模块,更别说到接口级别了。曾经我加入一个项目,我花了两个星期,把所有的流程梳理出来之后,甲方项目经理目瞪口呆,觉得不可思议。

插个题外话,用程序来管理程序员,是一个比较有意思的事情。在一定的技术体系下,程序员做的所有事情,都能可视化的展现出来,比如这里的调用链监控,又比如通过Swagger生成API文档。

调用链有一个叫openTracing的标准。大概的框架如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XZ1LCBeR-1583058823141)(https://skywalking.apache.org/assets/img/openTracing_base_structure.e1a47387.png)]

图片来自:APM和调用链跟踪

其中:

  • SpanContext:一个类似于MDC(Slfj)或者ThreadLocal的组件,负责整个调用链数据采集过程中的上下文保持和传递。
  • Trace:一次调用的完整记录。
  • Span:一次调用中的某个节点/步骤,类似于一层堆栈信息,Trace是由多个Span组成,Span和Span之间也有父子或者并列的关系来标志这个节点/步骤在整个调用中的位置。
  • Tag:节点/步骤中的关键信息。
  • Log:节点/步骤中的详细记录,例如异常时的异常堆栈。
  • Baggage:和SpanContext一样并不属于数据结构而是一种机制,主要用于跨Span或者跨实例的上下文传递,Baggage的数据更多是用于运行时,而不会进行持久化。

(上述文字同样来自:APM和调用链跟踪)

简单来说,就是一个Trace就是一组Span组成的有向树状图了。

但也不得不说,市面上参考这个概念的产品挺多,但满足这个规范的貌似并不多。这个规范也有待发展。

调用链监控的产品出名的也有好几个。这简单的介绍一下:

  • 点评CAT:一个做的很庞大的调用链监控系统,而且试图把其他监控也做进去。代码侵入性的埋点采集相对麻烦,但能做的很精细,对业务流程跟踪很有帮助。功能上确实很强大,界面上也很人性化。但对于国内企业开源的产品,说实话,个人信心不大,很多国内厂家开源的产品维护投入并不大,而且经常一段时间之后就不支持了。然后我花了两个小时,没有完全跑起来,错误茫茫多,而且都是代码级别的错误。
  • Spring Cloud Sleuth + Zipkin:听名字就是Spring支持的一套组件。利用Sleuth无侵入的采集数据,通过Zipkin展现。不过这界面,只能说很技术。
  • Pinpoint:韩国人做的,界面真好看。无侵入采集。唯一问题就是你得hold得住HBase,其他没啥毛病。
  • Skywalking:不管是什么,加入Apache组织并且孵化毕业的,总是让人觉得靠谱多了。而且社区确实很活跃,代码没啥毛病,界面也好看。用ElasticSearch存储、检索数据,无侵入采集,可以用@Trace注解手动添加跟踪信息,系统本身很简洁。

从总体架构上来看,调用链监控的产品大同小异,基本都是分为如下几个部分:

  1. 探针采集并上报数据。
  2. 处理、存储数据。
  3. 汇总、统计数据。
  4. 警告。
  5. 查询。

CAT、Pinpoint和Skywalking都提供JVM的监控。甚至CAT还通了简单的度量监控。

总而言之,不管用哪个,都得自己尝试以下,适合的才是最好的。

度量指标监控

度量指标监控通过Metrics来进行。很规矩的一个系统。
Metrics一般有5种基本的度量类型:

  • Gauges:度量,就是随便给个值,给什么是什么。
  • Counters:计数器,用来统计,可以自增自减。
  • Histograms:直方图,需要统计的数据就update()进去,统计数据的分布情况,比如最小值,最大值,中间值,还有中位数,75百分位, 90百分位, 95百分位, 98百分位, 99百分位, 和 99.9百分位的值(percentiles)。
  • Meters:速率(Rate)计算器,事件发生一次就mark()一下,自动统计最近1分钟,5分钟,15分钟,还有全部时间的速率。
  • Timers:计时器,针对事件发生事件的Histograms和Meters结,合Histogram某部分代码/调用的耗时,Meter统计TPS。

这五类度量指标非常完善,基本上各种业务指标都能知道了。其他一些工具针对度量指标的统计,比如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(企业服务总线)上。这些服务,可能分布在多个实际部署的系统上。

软件开发随笔系列一——分布式架构实现_第7张图片

那个时候,系统的调用都是通过ESB为中介的。容易导致ESB本身成为瓶颈甚至故障点。

服务化的关键则在于良好的接口契约定义(接口名称、入口、参数、响应)。而接口契约定义的难点在在于粒度。所谓粒度可以简单的认为是一个接口对应实现的功能多少。这个基本上很难有一个定论,大有大的好,小有小的好。只能根据业务具体分析了。但我推崇的方式是按专家模式来划分。所谓专家,就是掌握一定知识(数据)同时只做自己擅长(能做)的事情。当然,也有其他的比如代理的模式,但一般意义上,先把每个服务当做专家总是错不了的。

至于服务的交互协议,我觉得基于HTTP协议的RESTful + JSON是最好的选择了。越重的模式,或者包装越多的,总是适应面受限。

不管如何,服务化这个概念,基本上大家都很认可和接受了。从SOA转向微服务,则是在物理实现上,分成更多的微服务系统。也就是在部署上,拆分的更细。一个独立部署、运行的系统所包含的服务更少了。在讨论SOA的时候,提供服务的系统都是“大”系统,一个系统包含很多的服务接口。但在讨论微服务的时候,提供服务的系统都是“小”系统,系统数量更多,每个系统提供的服务更少。

软件开发随笔系列一——分布式架构实现_第8张图片

微服务带来最大的好处是应对变动。在单一应用架构中,任何的变动都将导致整个平台的整体停机发布,而且一旦出问题,版本回退、下线等会造成业务的暂停。而微服务架构中,只需要升级对应的服务,无需整体停机。当然,微服务架构也带来了整体的开发和管理的难度加大。相对简单的小型应用系统,或者开发一个全新领域的应用系统,谨慎使用微服务架构。从单体应用开始,逐步分离可能是一个比较好的方式。毕竟,借助于服务注册/发现工具(如Spring Eureka),调用者并不需要事先知道服务提供方是如何部署的。

而且微服务架构,可以使得系统的每一个部分都可以独立、并行的开发、调试。当然,需要你有足够的人手和足够强大的管理能力。

怎么划分微服务呢?这可能是一个没有标准答案的。我只能自己的理解:

  • 第0原则:能不分就不分,尤其是相对简单的引用,不是那种各种不同业务混在一起的系统,没必要着急分成多个微服务。不能为了微服务而微服务。
  • 第1原则:业务隔离,只有业务隔离,才能更好的应对变动,否则各个微服务混在一起互相依赖,会带来更多的工作量和开发难度。比如一个业务体,有电商,咨询,互动等业务,这三个进行隔离,是毫无问题的,不会互相影响。再细一点,比如一个数据采集,针对几个网站进行采集数据,分成单独的微服务,任何一个挂了不影响其他工作。
  • 第2原则:性能驱动。如果经过拆分之后,一个服务系统有A、B、C三个模块,而这三个模块其实是有相互依赖关系的,那个有问题都或多或少影响业务。这个时候,还拆不拆?如果不是性能撑不住就不拆!通过简单的负载均衡都无法解决,再考虑拆分。而且首先把有强一致性要求的部分,比如电商的库存模块拆分出来。

也有设计师把一些公共的模块作为独立的服务,其实问题也不大,Spring全家桶里面很多都可以认为是公共服务,但就一个要求,这个公共服务足够稳定,也足够抽象,可以复用。如果有一些业务变动,就得改好几个服务,就得不偿失了。

每一个微服务,都是一个独立的运行节点,都要考虑这个节点是否安全(网络、单点故障),数据存储(数据库集群),复杂的分布式事务如何实现等等一大堆的问题。

得益于容器技术的不断改进、发展成熟,在很短时间内,部署一套需要十几个甚至更多运行节点的应用平台已经非常方便。这里主要就是说docker了。它让一套复杂的系统的运行管理变得很简单。

服务的编排

一种理想的状态,是各个服务之间是没有必然关系的,完成一个业务完全由一种叫服务编排的技术/工具来完成。

如下图:
软件开发随笔系列一——分布式架构实现_第9张图片

这种编排方式,事实上是一种纯粹只存在于理想中的状态。或者,用在文档里面,表示一个业务流程需要依赖那些基础服务是可以的。几乎任何系统,业务流程实现的复杂程度会远远超出最初的想想的。最初的架构设计,只会把最主干的流程描绘出来,再开发过程中,各种业务的分支、异常情况,都会被发现,这个流程就会面目全非。最后描绘出这个流程则包含分支、回退甚至循环等。而一个图灵完备的程序语言所包含的操作无非也就是顺序、分支、循环这些。

也就是变成下图这样了:
软件开发随笔系列一——分布式架构实现_第10张图片

以前的ESB工具,真的提供这样类似工作流的可视化工具,让开发人员不用写代码,就把各种服务以各种形式(顺序、分支、回退等)组织在一起。但我们知道,集中统一的ESB本身就会变成整个系统的性能瓶颈(事实上也是的,我从事过核心项目的银行,ESB都会变成玻璃娃娃,以至于大量的复合型的服务在后台直接提供,ESB提供毫无价值的“穿透”处理——就是没用你也得来我这打卡),甚至整个系统的风险点。然后在这基础上,提出一大堆诸如“统一对账”、“统一冲正”之类的让我头大的需求。

曾经有个笑话,国内某大型股份制银行,使用全新的SOA架构开发实施Corebanking核心业务系统,花了10年没上线。大家花了无数时间在ESB上画流程,然后发现业务流水被拆碎了没法跟踪,就在ESB上扩展建立适应所有业务的流水表。听得我心惊胆战。

从更高的层面,架构师确实希望各种服务井然有序,不要乱七八糟的关联在一起。如果形成一种网状的调用关系,确实是很可怕的。

软件开发随笔系列一——分布式架构实现_第11张图片

比如,用户支付订单的时候,订单服务调用库存服务扣减库存,然后,然后调用商品服务更新销量,调用客户模块获取客户地址,然后调用统一支付模块,支付模块调用积分支付,然后调用现金支付,最后回到订单服务调用积分模块给用户添加积分,等等。诚然,业务的具体路程,确实只有在实现这个业务的过程才是最清楚的。而且,除了正常情况往下调用之外,还需要处理异常,比如积分不足导致支付失败,需要回滚,或者其他的补充处理——跨系统的流程和事务控制。

我们知道,类似工作流引擎这样的服务其实是很“重”的,比较适合用于OA、MIS(比如信贷审批)这样的系统。这些业务的流程很复杂,多变,不但需要针对不同的情况设定不同的流程,而且流程还经常改变。使用工作流会非常方便。但这些系统的特点都是并不追求高并发和短响应时间。而银行交易系统、电商系统虽然流程也复杂,但是更加追求的是TPS。这种模式并不适用。

然而到现在还有一些厂商吹嘘自己的这类系统很厉害,无所不能还支持很高的并发和实现很短的响应时间。反正我是不信的。

一个好的设计,应该是体现服务分层了。

软件开发随笔系列一——分布式架构实现_第12张图片

我们把服务分成两层:

  • 聚合服务:这类服务是为了实现某些复杂的业务流程,实现该过程需要调用多个原子服务,并且控制流程中的事务状态。
  • 原子服务:我们定义为基础的、可复用的服务,同时并不依赖于其他服务。

经过这样的编排,每个服务都成为了自己领域的专家了。但有两个问题需要考虑的:

  • 原子服务是否抛出异步的消息,让其他原子服务去处理呢?我认为是应该的,这个在后面讨论。当然,严格不依赖其他原子服务,只在聚合服务处理,也不是不行。
  • 这种分层,依然是一个理想状态,只是相对容易实现的理想而已。一个项目开发到后期,或者升级的时候,开发人员很可能会因为各种原因而打破这种分层。所以调用链的监控是很重要的。

而且,分层的方式,也加大了微服务划分的难度。我们知道,划分微服务主要是为了隔离变更,而分层依赖关系的存在,使得某个微服务停止的业务影响面会扩大。这就取决于对业务的理解能力了。

同步和异步

服务之间的调用方式分为同步和异步两种。一般来说,客户发起一个情况,肯定是需要得到一个响应的,比如转账,用户发起之后,肯定希望立马得到结果转账是否成功。但有些请求,不需要,或者不能立马给出答案的,只能告诉用户:我收到了,等消息吧。

同步和异步的实现技术现在都很成熟。但我个人来说,对象化的RPC(Remote Procedure Call)实现都不是好的实现。从最早的CorbaEJB我都认为不好。分布式架构必然会面临异构体系的,比如我个人比较习惯用JAVA,但也不排除我做的应用里面包含PHP、Python等开发的程序。包装的越多的协议,就越不稳定。当然,RPC体系中很重要的寻址路由这部分很复杂的东西,我们不深入讨论,毕竟对于大多数人来说,用好比如Eureka之类的开源工具就很好了。但是,对于最终“调用”(获得服务地址之后)的那一下,HTTP/HTTPS + JSON应该是可见的未来中最简单、最实用、适用面最关、包容性最好的方式了。简单就是最好的。

顺便提一句,基于HTTP请求的处理过程,我认为充分利用好HTTP Status code来表示错误是非常优雅的。比如,大家都知道200是成功,500是大家都害怕的内部错误,404找不到页面这些,基本上大家都很清楚。而且任何一个HTTP客户端处理响应的时候,都会为所有的错误状态触发错误事件。从错误的角度,不管是网络错误连接超时(当然这个时候没有状态码了)还是后台返回的错误,都是错误,都是需要错误处理的。从业务的角度,错误也是一种业务逻辑,而不仅仅是异常。而且,HTTP状态码的表示基本能覆盖错误类别,比如:

  • 404:找不到商品和找不到页面,其实没啥区别,你要对一个不存在的商品下单,返回404挺好的。
  • 400:发送了服务器不可理解或者不接受的参数。
  • 401:没有授权。
  • 403:服务器拒绝执行操作。

等等状态码,语义本身也是很清晰的。尤其采用RESTful风格定义WEB API的时候,本身也会经常出现只接受POST方法的用了GET方法请求之类的方法错误(405)。我的理解是,既然HTTP客户端无论如何都需要用ERROR(错误)函数去处理错误,为什么要在SUCUSS(成功)函数里面去处理错误呢?某厂封装的客户端工具就让我很无语,硬生生重新封装了一层,除了他自己认为的类似网络错误这样的,才会触发ERROR。在成功函数里面去处理错误。这回路我不能理解。

在分布式系统的实现中,也有人提出全面基于事件模型的机制,也就是全部都是异步调用,必须同步的场景,也是同步转异步,然后等待结果。很强大的理想。但我认为真没必要。同步和异步都有适合的场景,而且,同步调用本身更简单。异步的作用在于:

  • 增加系统的容量:请求发出去就不管了,不需要同步等待结果。参见各种NIO机制。
  • 降低模块间耦合:完全通过消息交互,需要处理这个消息的模块自行领取任务,处理模块的变更与消息发送者基本无关。
  • 延迟一致性实现:不需要强一致性的场景,利用异步机制实现最终一致性,降低主系统的负担。

异步机制的使用限制也很明显,主要就是不需要强一致性的场景了。如果需要保证一致性,或者从流程上必须得到响应然后执行下一步的,还是踏踏实实用同步调用吧。

如下图的例子:

软件开发随笔系列一——分布式架构实现_第13张图片

这是一个电商系统的实现例子。实线是同步调用,虚线是异步调用/通知。

用户从购物车中选择商品进行结算到支付的过程。在提交订单的时候,已经确定了:

  • 需要购买的商品;
  • 是否使用优惠券或者积分;
  • 收件人和收货地址;
  • 快递方式等。

前面提到,采用服务分层的方式,业务流程在聚合服务中体现,原子服务不要交叉调用。所以,提交订单的请求,会进入“购物服务”,这是一个聚合服务。负责完成整个订单的生成处理。

创建订单的流程涉及如下核心服务:

  1. 库存服务:对选中商品检查其库存,如果满足销售条件,则锁定库存。
  2. 商品服务:查询商品信息,包括商品的价格,如果价格变动则订单需要重置;另外包装规格、重量等生成运输分拣规格。
  3. 账户服务:这里的账户包含客户的积分、储值卡、优惠券的内部数字资产账户,在此场景中,需要保留对应的积分和锁定优惠券(类似于冻结,避免超支)。
  4. 订单服务:创建订单,清除购物车。

按照该系统的设计,上述四个步骤在创建订单过程都是必须的,而且是有序的,并且是一个完整的“事务”——如果订单创建失败,需要释放库存和积分/优惠券。

从“购物服务”(聚合服务)到各原子服务之间都是同步调用(基于HTTP)。而原子服务之间没有直接的调用关系。

同步调用的最大问题在于超时。如果调用失败,不管是直接被拒绝,还是返回错误,都是明确的状态,可以采取相应的处理手段。但如果超时也就意味着处理状态不明确,就会导致数据不一致。超时一般的应对机制是反向操作。这在后面“数据一致性”中详细讨论。

各个服务处理完自己的事情之后,可以发布“事件”,比如:

  • 商品库存紧张事件:如果库存紧张,发布此事件,通知服务处理此事件,给采购人员发通知。
  • 订单创建成功事件:订单创建成功会有很多模块关心,比如:
    • 分销模块:分销商关注他的客户下单了,并且会跟进他的支付状态。
    • 定时任务:记录订单,处理超时订单。

异步消息或者叫事件,被消息创造者发出来之后,创造者本身可以不关心,或者说也不应该关心消息是否被处理以及处理结果。换句话来说,就算这个事件没人处理,也不应该影响业务。比如,库存紧张的事件,如果有模块处理了,采购负责人会第一时间拿到通知。但没有模块处理的话,采购负责人也可以自己到系统查看库存情况。

从技术的角度,可有如下的模型:

软件开发随笔系列一——分布式架构实现_第14张图片

可以认为,每个服务模块,都应该具备两个入口:

  • REST API入口:响应HTTP同步调用的请求。
  • 事件监听端口:侦听所需要处理的事件。

消息服务需要单独部署,避免影响主要业务流程的性能。具体产品如Kafka等非常好。如果简单地使用,Redis也不错。

事件机制一般采用消息服务的发布订阅(PUB/SUB)模式实现。

在软件架构中,发布订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。

(来自百度百科发布订阅)

和通常的先进先出点对点的队列不一样,发布订阅通过“主题(Topic)”区分消息的类型。

软件开发随笔系列一——分布式架构实现_第15张图片

在这个机制下可以认为,消息并不是由发布者直接送达订阅者。发送者只是发送了一个属于某个主题的消息,剩下的就不管了。这样提供了极大的松耦合实现可能——可以任意增加对特定主题事件处理的模块。

比如,“订单支付成功”这个事件可能有如下模块分别处理:

  • 积分模块为用户增加积分。
  • 通知模块为客户发送支付成功消息。
  • 客服模块让客服和用户确认订单。
  • 分销模块为分销商增加业绩。

以上等等,增加模块,或者减少模块,对主流程毫无影响。当然,也有极端情况,可能消息没人处理。这就需要设计事件模型的时候,有一个整体的考虑了。

不同的订阅者做不同的事情,在这个场景是最合适不过的。但如果订阅者是一个集群,比如处理用户积分的订阅者——积分模块——有多个服务同时处理任务。这就带来一个问题:同一个事件会被集群中所有的服务都拿到。如果都去给客户增加积分,就出问题了。所以,订阅者的事件处理机制,很重要的一点就是实现幂等性。也就是同样的工作只能处理一次。

刚刚的例子,通知支付完成,需要给用户增加积分,只能增加一次。多个模块只能有一个处理,其他的不处理,或者说多次处理和一次处理的结果是一样的。

实现的办法可以通过消息发布者为每个消息附加一个nonce值。这个值一般只要保证短时间内唯一就行,比如发送者标记+时间+随机数+内存顺序计数,或者通过redis自增都行。收到消息订阅者,通过redis做一个锁,成功的处理任务,不成功的就放弃这个消息。这是一个比较简单的机制。

消息的另外一个问题就是时效性和补偿机制。由于发布者并不考虑是否有订阅者,所以会有一个极端的问题,就是某类消息确实没有订阅者。这些消息会被堆积在消息队列中。所以一般需要为消息设置一个有效时限,比如2个小时。另外,不管哪个消息服务的实现,其实都无法保证100%的消息不丢失,这也是一个现实。所以,也就意味着,如上面的例子,用户支付成功处理积分,可能这个消息遗失,会造成用户积分没有加上。

对于系统的实现来说,首先尽量保证所有的消息都被处理,但有一些消息如果没有处理,其实也问题不大——比如用户通知——就不管了。但另外一些,比如积分,必须处理的,就需要补偿机制了。比如在日终统一的批处理任务把漏算的积分都给补上。

插个题外话,一般日终批量处理,是几乎所有最终一致性的兜底机制。

总结一下消息/事件机制:

  • 不要强求所有的请求都通过事件机制处理,这样会大大增加系统的复杂性。
  • 可以异步处理的,都通过消息发布出去,让消息订阅者自行处理事件。
  • 每个服务都可能是消息发布者,同时也是消息订阅者。
  • 事件处理应有补偿机制,如果没有被处理也不会造成损失。
  • 事件的处理要保证幂等性。

数据一致性

在分布式系统中,数据一致性是一个必须面对的问题,而且还是一个很严重的问题。不管强一致性还是最终一致性,都必须保证一致性,只是实现一致性的时机问题。真的不需要实现一致性,或者弱一致性的场景,其实并不多。

强一致性

究竟哪些是强一致性呢?前面谈到过,如果只有一个数据库,强一致性是毫无问题的。为啥会有一致性问题呢?

  • 同一份数据存放在多个地方,比如Redis缓存、主数据库、从数据库。学习关系数据库范式的时候我们就知道,数据冗余了就可能不一致。
  • 存在事务的要求,就是多个数据要么一起更改,要么一起不被更改,这个事务过程被打破,或者终止了,而且没有补偿机制,ACID不完整,导致不正确的数据出现。
  • 把某些信息通过网络传传送给别的服务的时候出现了意外,信息没有正确送达。

在分布式体系下的事务就是分布式事务了。不但涉及到事务保障/补偿机制,还涉及的应用实现的逻辑如何配合,比较复杂。消息在网络上传递不管同步还是异步,也不管采用哪种分布式事务的实现机制,也都应该有对应的补偿机制。后面专门讨论分布式事务。

这里主要考虑一下第一个问题。为什么要把同一份数据存在不同地方呢?最重要的原因一定是性能提升。把redis作为高速缓存,快速存取数据;数据库读写分离提升读取效率等。

不同地方的数据内容,严格意义上来说:

数据冗余不一致

如果这三个地方数据严格一致,并且不牺牲可用性(响应时间不延长),那就什么问题都不会存在了。但这基本不可能存在。所以问题变成了哪些数据或者场景数据读取都必须只能在主数据库上,当然,还得加一个限制,这些数据变更发生之后很有可能立马有人访问的。如果这些数据很长事件都没人访问,其实也就无所谓了。

在银行系统中,账户表,包括账户的属性,限额,余额等,在交易场景中,有着非常强烈一致性需求。在手机银行把钱都转走,同时在ATM把钱取出来,如果账户余额不一致,银行就可能亏了。但在报表场景,账户数据完全可以慢慢的同步到另外一个数据库去。

我们可以把账户表这种表当做一类,比如叫I类,属于核心实体属性表,更新不但频繁,还必须必须保证原子性。这种数据就是强一致性要求的。最理想的办法就是,在哪里写的,就在哪里读——数据库单点的操作都能保证事务一致性。这部分我觉得其实真没有什么特别好的办法。几种有用,但都不优雅的方式:

  • HA高可用方式:前面提到过,对性能提升无帮助,只解决单点故障,保证业务连续性。
  • 分库分片:不同的业务模块单独成库,比如单独有账户数据库;然后按特定规则(比如按地区)把账户数据分成多个数据库(Sharding),如账户_北京,账户_广东,降低每个数据库的数据量和访问压力,如果某一个崩溃了,也不至于影响所有的业务。

分库和分片是目前比较常用的方法。分库还说好,每个服务自己访问自己的数据库,不会有太大的问题。但分片,意味着一个SQL到来的时候需要分析这个SQL应该在哪个Sharding库执行,甚至个别情况需要跨库执行。这和数据库内置的表分区很像——把一个表的数据按规则存放在不同的文件,找数据的时候只需要在一个文件找——但这是多个数据库实例组成的分区。这一般需要通过一个Proxy(代理模块)来处理。

软件开发随笔系列一——分布式架构实现_第16张图片

如果不差钱,每一个单独的数据库都加上HA。基本完美了。

还是那句话,基于数据同步的强一致性保障,一定是有代价的。比如交易时间延长,牺牲可用性。

在电商系统中,类似需求的数据是库存,如果经常做限量促销,抢购的,就更需要严格保证库存数据的一致性。另外,如果建立了虚拟资产,比如积分,储值卡的,其实也就是一个账户系统了,跟银行账户一样对待。

第二类数据,我们叫II类,是交易流水。一般来说,每一个交易流水都是一个独立的实体,会尽量设计成为不可修改(银行交易流水的操作主要就是冲正,一般会新增一条记录表示冲正,而不去修改原来的记录)。也就是在系统中,这类数据就是飞快地不断地插入。这类数据不能丢,但基本也不会有访问冲突。我们知道,不管什么数据库,插入都是很快的。甚至我们可以采用多个数据库实例分别插入部分数据,最后合并。这种数据完全可以使用最终一致性实现。

第三类数据,我们叫III类,是登记簿。电商的订单表,其实是很典型的登记簿,插入一个新的订单之后,随着业务变化,需要更新订单的状态。最基本的流程是系统创建订单记录,然后提交支付,支付通知回来后更新订单的支付状态,前端发起查询订单状态。实际上就是一个时序操作的问题:

  • 创建。
  • 修改。
  • 查询。

我们试想数据库读写分离的情况,创建和修改没问题,但在从库查询可能会有延时,第一时间查询不到,告诉用户再查一下,应该关系不大。其实银行系统里面也有大量的登记簿,比如中间业务的缴费登记簿,理财的销售登记簿等。

再考虑用Redis缓存集群来保存数据的情况,Redis集群中的主从也会有数据不一致/延迟的情况,所以同样的问题就是用户查询的时候,有可能查询不到,需要重试。当然,这里的问题是,Redis宕机了这个订单可能丢失。所以这类数据不适合直接记录在Redis,还是应该记录在数据库,但读取订单的时候,可以把订单数据缓存到Redis,提高访问效率。只要解决订单变更同步问题。

第四类数据,我们叫IV类,是相对稳定的数据,比如产品信息、客户信息等,变更比较少,但经常被读。这类数据基本肯定不要强一致性。我的建议就是放到Redis缓存起来,有修改就同步。

第五类数据,我们叫V类,是易变、可损失的数据,比如阅读量,埋点跟踪等。变更特别频繁,但丢了一些其实影响不大。其实就是可以一定程度放弃一致性的体现了。

软件开发随笔系列一——分布式架构实现_第17张图片

总结一下,对于强一致性需求的数据没什么特别优雅便捷的方式,因此,最重要的是在设计过程识别出那些数据必须强一致性处理。这个和设计数据模型的时候识别实体一样重要,但得在此基础上考虑业务流程的需要才能识别。

最终一致性

最终一致性的内在含义有两个:

  • 数据不能丢。
  • 一段时间之内数据可以不同步。

所以,此类数据必须首先保存在持久化存储的数据库中。然后通过一些列机制同步给别的服务。至于同步的延时容忍,取决于业务需求。比如早年间的信用卡,压卡消费之后,可能需要好多天才会同步到银行。又如早年的银行积分,很多银行都是每天晚上才算出来给你增加一天消费所得的积分。当然,随着技术发展,这种延时的变得越来越短,现在可能你刚消费几分钟之后,积分就更新了。毕竟竞争这么激烈,尽快能把收益反馈到客户手中,能提升客户服务水平。

前面也提到了,在交易层面最终一致性的实现首先是基于消息机制或者事件模型。同时提供事件消息的补偿机制,这样可以不依赖于消息服务的可靠性——当然,消息服务的可靠性越高越好。由于可以通过事后补偿,所以,即便消息发布失败,也没关系。

这种方式也叫“最大努力通知”。前面已经介绍很多基于发布订阅(PUB/SUB)模式的异步消息机制的实现方式了。这里主要从数据一致性的角度考虑。最终一致性不等于不需要一致,最后必须保证一致的。但任何系统操作都存在失败的可能,比如发布消息就失败了,所以补偿机制很重要。一般补偿机制有几种:

  • 重新发布:如果碰到消息服务连接失败的情况,可以把消息放到一个本地的重新发送队列中,按照一定的规则重新发送。由于允许消息丢失的,这个重发队列其实可以简单的就是在内存中。但如果持续失败,就放弃。
  • 提供查询接口:消息订阅方可以查询用以校对数据。但这个查询接口会对系统有一定影响。因为订阅方并不知道什么消息没有发送过来,所以只能按照时间策略,查询出一个时间段内所有的消息来核对那些没有收到。比如每小时查询一次,3:30的时候,查询2:00到3:00的数据。
  • 日终对账:一般会在第二天凌晨开始对上一天的所有交易数据进行总的核对,一般会遵循某个原则去调整数据实现一致性。考虑第三方系统对接的情况,比如和银行系统对接,一般第二天会下发对账文件。

第三方支付的模式,是一个很好的例子:

软件开发随笔系列一——分布式架构实现_第18张图片

由于第三方支付真正的支付交易本身是在第三方支付客户端内部实现的,并不经过商家的服务端,因此,需要等待第三方支付系统通知支付的结果。而如果等不到结果,我们就会主动去查询这笔支付的结果。而如果查不到,一般会认为支付失败了。但实际上,并不一定真的就是失败。事后如果查询到支付确实是成功的话,或者在对账的时候发现状态不一致了,就需要给客户退款了。

对于电商系统内部来说,包括为客户累计积分、为分销商计算提成、新用户注册通知、给客户发通知等,都可以采用这种方式。

前面说这么多是通过消息服务把信息传递给别的服务,并期待别的服务能实现最终一致性。

而另一种需要考虑的场景是数据同步,比如数据库的主从同步(读写分离)和数据库与高速缓存的同步。主要体现在如何高效的获取数据,而读到的数据可能是脏数据,如何处理或者补偿脏数据带来的问题。主要的补偿有两种可能:

  • 如果是客户直接请求的,可以让客户过一会儿继续请求。
  • 采用积极同步的机制,在不一致发生的时候主动弥补。

这里先说一下数据库同步。一般数据库的数据同步分三种模式:

  • 同步复制:所有节点数据都写成功,事务才结束。说白了就是牺牲可用性了。读的性能越好(节点越多)、写的性能就越差了。
  • 异步复式:也是通常的实现,主节点写完成事务就结束,通过单独的线程把bin-log变更同步到从节点上。但如果主节点突然宕机,有可能丢失数据。
  • 半同步复制:除了主节点,还保证至少一个从节点收到bin-log文件事务才结束。牺牲了一定的写入性能,但最大程度保证了数据一致性。

软件开发随笔系列一——分布式架构实现_第19张图片

同步复制方式这里不讨论,我认为实用性不大,前面“强一致性”的已经讨论过。如果能容忍写入性能稍微下降一点(配置更好的数据库服务器,万兆网络会好点),半同步是挺理想的模式,毕竟一个系统中查询数据库的压力非常大,虽然在DAO层想尽办法做缓存了,压力还是很大的。

如果考虑写节点的单机故障问题。需要把上面的部署修改为:

软件开发随笔系列一——分布式架构实现_第20张图片

双主多从的结构,主要要点是:

  • 双主同步复制数据。
  • 通过VIP(虚拟IP地址)切换实现双主的切换。

根据数据库同步的特点,结合前面的例子,我们可以把订单,评论等内容在写节点写入,在读节点读出,如果读不到也不要紧,反正早晚能读到的,一般再刷新一下,就出来了。

数据同步的另外一个场景是数据库和高速缓存如Redis之间的同步。尤其对于频繁读取,修改又少的数据来说特别适合放到Redis提升读取的效率。当然,内存要大,服务器成本上去了。对于缓存而言,也有两个要求:

  • 数据是可以恢复的,就是缓存里面没数据不要紧,可以自动恢复;
  • 数据能和数据库同步。当然这个同步不可能保证原子性,但延时也应该是在接受范围的。

一个常规的做法是:

  • 对于量较少的数据,服务启动的时候一次性加载到Redis中;
  • 对于量较大的数据,读数据的时候,先从缓存中读,缓存中没有就到数据库中提取到缓存,然后读缓存。

变更的同步也有两种做法:

  • 基于数据库触发器Trigger,发现数据变动,自动更新缓存对应的条目;
  • 基于DAO层实现,更变数据的时候,既修改数据库,也修改缓存。这种修改设置可以放到一个事务中。降低不一致的可能。

当然很多DAO层的开源实现比如Mybatics对于缓存的支持都挺好的。

可放弃的一致性

不要一致性的数据虽然少,但还是有的。为什么要放弃呢,主要就是性能问题。这种数据变更非常频繁,比核心业务频繁的多,同时又不影响业务。一个典型的例子就是商品的阅读量,文章阅读量,埋点跟踪等。这些数据很有用,能帮助更好的改进业务。但这些数据又不是直接影响交易的。也就是,精确度不需要那么高。

对于阅读量这种指标的处理,会采取一个二段模式:

  1. 第一:记录在Redis中,来一次就自增1。
  2. 第二:定时同步,比如每5分钟把当前的值记录到数据库中。

一般情况,读取指标也是直接读Redis,不会有什么问题。问题在于如果Redis宕机了,这几分钟内的点击量就有可能丢失了。我们不会为了这些可能丢失的数据采取更多的手段。

而类似埋点这样的数据,一般处理比较复杂,很多人会选择用给第三方的埋点服务,把压力给他们。但如果非要自己做,这种数据直接就发送给专门处理埋点数据的服务器去,不要影响主服务的性能。而如果埋点服务的处理机制一般也是,尽量去处理所有的数据,但如果有数据丢失了就丢失吧。

分布式协调

微服务体系由多个独立的服务组成完整的业务流流程,为了实现数据一致性,往往还需要对流程中的事务,操作顺序等进行协调。

分布式事务

数据一致性往往跟事务关联在一起。再原来的单体应用中,数据库的事务基本可以解决所有的问题。而在分布式体系中,一个业务流程的处理,涉及多个独立的服务以及多个数据库。就无法依赖数据库的事务来保证“一致性”了。但从逻辑上,事务的“原子性”还是需要保证的——数据要不全部更新,要不全部不更新。

所以,简单的来说,分布式事务就是在分布式体系下实现事务,把数据都正确记录到各自的数据库。分布式事务有很多种实现方式,包括:

  • 两阶段提交(2PC):两阶段提交是一个比较传统的实现方式,比较依赖于数据库的实现。由分布式事务管理器协调多个数据库,把事务提交拆分为2个阶段:prepare和commit/rollback。也就是本来只有一个数据库的时候,直接提交(commit)后,数据就没法撤销了。现在,多个数据库,得先让大家都准备好(prepare),然后让大家一起提交(commit)。而如果有人没准备好,就一起回滚rollback好了。两阶段方式除了依赖数据库实现之外,还会把事务的时间拖的很长,如果处理的环节比较多,占有资源会比较严重,实际上就是牺牲了部分可用性了。
  • 补偿事务(TCC:Try-Commit-Cancel):依赖一个中间状态,把事务分成三个阶段,在Try的阶段检查资源并隔离资源,Commit则是完成真正的操作并清除中间状态,Cancel只清除中间状态归还隔离资源。所谓补偿,实际上就是补偿这个中间状态。可以通过TCC事务管理器统一协调操作步骤应该采取什么操作。
  • SAGA:基于反交易(撤销操作/冲正)的实现,简单的来说就是,每一个交易都应该有一个对应的反交易用来清理掉正交易的痕迹。典型的SAGA通过消息来触发下一步操作。

两阶段提交(2PC)我相信大家都不太会用,对数据库依赖太强,而且牺牲可用性。TCC和SAGA看起来有点像,但认真分析的话,TCC其实挺难实现的,对代码改造、甚至数据模型都有侵入的要求。用一个转账的例子(A给B转账100元)来对比:

  • TCC实现:Try阶段,实际并不是检查A的余额,而是可用余额。这个可用余额什么概念呢,就是余额减去被隔离(术语叫保留,也有叫部分冻结)的金额。如果可用余额足够100元,就把这100设置为保留金额(中间状态),这时候A的余额没变,但是其实有100块钱是不能用的,也就是可用余额减少了。然后B账户没问题,给B账户增加100,实际扣减A账户100元,清除保留金额(中间状态);如果B账户有问题,清除A账户的保留金额,什么都没发生。
  • SAGA:把A账户100元转到一个肯定没问题的中间账户,然后让B账户入账,如果B账户入账失败,对A账户的出账交易调用反交易,冲正掉交易。

要说明一下,只是一个好说明的例子。银行系统不这样干。可以看出,TCC真的很复杂。其实同样是把事务的逻辑交给了业务本身,SAGA相对简单多了。当然,TCC的好处是,事务处理的过程中,如果有别的交易来查询A的账户,值还是交易之前的,只有事务成功了,才会改变。SAGA模式则在交易过程中值就改变了,如果失败,再变回来。实际上,大部分的银行跨行转账,都是类似的实现。毕竟,你发起的交易要转出去,我就给转了,余额就减少了,有问题,给你回退回来,也很合理。

软件开发随笔系列一——分布式架构实现_第21张图片

这也是一个简单的示意图而已。准确的来说,为了保障交易成功率,第一步转账一般都是转入一个肯定没问题的内部账户的。晚上批量在入账清算。

类似场景在电商中也很常见。
软件开发随笔系列一——分布式架构实现_第22张图片

每一个步骤都是独立的事务,但如果最后一步失败了额,对之前的操作步骤调用对应的撤销服务。这里需要强调一点,如果当前操作步骤是超时,前面提到过,也就无法确定交易的状态了。这时候对当前操作,也必须调用反交易。

其实冲正交易在银行系统中是非常重要的,几乎所有账户操作都有对应的冲正操作。尤其是操作超时的情况,基本上跟着下一个交易就是对上一笔交易的冲正。

从这里可以看出,SAGA模式如果事务嵌套很多层,可能就是一个悲剧了。而且,还有一些约束:

  • 对于外层SAGA来说,不能保证操作的原子性,就跟刚刚说的转账的例子一样,我认为一般情况都可以接受。
  • SAGA内部的每一个事务都是独立的原子服务。
  • 反交易需要保证能成功。

需要注意的是,反交易并不能消除正交易的操作痕迹。毕竟并不是数据库的rollback。只是删除了这条交易记录,或者给这个交易记录增加一个作废/冲正状态,更多的是增加一条新记录,表示之前某一条记录作废。

由于需要保证反交易交易一定要成功,所以,系统往往会采用多次重试的机制(做一个冲正队列表,定时执行)。因此,反交易必须保证幂等性。一个比较好的事件规范是交易的最初发起者应该给一个唯一的标识。反交易只发送这个标识。就算多次发送同样的交易,也能保证同样的效果。

另外,可能多次尝试,依然会失败的情况,就只能留到日终的时候对账了,甚至,人工介入调整了。

我认为,对于长事务的支持来说,这是很好的机制。

关于分布式事务是否应该基于消息机制/事件模型。有两点说明:

  • 第一,我认为这种有强逻辑因果关系的调用,其实也属于强一致性的一种体现——虽然不像单个数据库服务中的事务那么紧凑——但至少也是一种较强的一致性场景吧。有很多对SAGA实现的理解会基于事件模型,也就是消息异步机制。我觉得这个太难协调操作的因果,或者顺序了。如果采用事件模型,基本上必须得有一个“上帝”——协调器,或者叫事务控制器——来记录、控制整个过程。总之,就是会实现起来很麻烦。是否适用,就自行判断了。
  • 第二,据闻来源于eBay本地消息表模式,在本地事务处理完之后,发布一个消息,通知下一个步骤开始处理。而为了保证这个通知一定能发出去,除了依赖消息服务的可靠性之外,还需要在本地建立消息表,实际上是“本地处理完成+消息表插入完成”才算是本地事务完成,如果消息发送失败,可以通过消息表补偿。但是否认为消息发送失败就是事务失败,是否可以启用补偿,就需要业务逻辑来确定了。

总而言之,利用异步来实现同步的场景,很麻烦。如果团队技术能力很强,可以尝试。毕竟,分布式事务就不是一个简单的事情。

分布式锁

和多线程环境一样,多个线程的工作可能互斥,就需要“锁”。得到“锁”的线程才可以干活(进入某段代码),没有得到“锁”的线程也要做这个事情(进入同一段代码)就只能等待,等拥有“锁”的线程把“锁”释放出来,然后,抢到这把“锁”。

多线程都在同一个进程里面进行,所以进程内就是实现统一协调。而分布式环境中,多个服务的协调也需要一个“上帝”来仲裁。一般可以用:

  • Redis:利用 Redis 的 setnx 命令。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。新版本Redis可以实现set命令中带nx和超时参数。把锁超时也同时设置上去。
  • Zookeeper:利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。

使用Redis做分布式锁比较简单,setnx一个key,如果成功就是获得了锁了。如果失败,就没有获得锁。得到锁的服务,完成任务之后,删除这个key就可。但有几个事情需要考虑:

  • 别误删了。这个key,谁都可以删除的,一旦删除,也就意味着锁失效了。
  • 超时到了,任务没完成。感觉这个一般不常见。如果任务时间很长的,最好的办法就是自己给自己续命了。不设置超时是不合适的,一旦服务崩溃,这个锁就变成“死锁”了,只能人工处理了。
  • 如果只需要一个服务干活,拿到锁的工作,没有锁的直接放弃的话,没啥问题。但如果所有的服务都必须等到这个锁,相当于排队工作的话,Redis并不会通知其他服务什么时候锁被释放。就需要每个服务循环尝试set这个key,一直到成功。并不是那么优雅的方式。可以结合守护线程侦听消息队列,通过消息唤醒守护线程再来抢锁。

而使用ZooKeeper实现分布式锁的最大好处是可以通过Watch机制,锁被释放的时候所有等待锁的服务都能得到通知,被唤醒继续抢锁。基本的方法是在一个普通节点下创建临时节点,比如在Lock下创建临时节点,创建成功表示获得锁。释放锁的时候,删除此临时节点,每个服务都得到通知。而且,当有锁的服务宕机,锁会自动释放。

分布式锁本来是一个很难实现的技术,感谢各种大拿提供各种开源组件,让这种事情变成简单的,用工具。作为并发的协调机制,并发锁本身是一个很重要的技术,也是一种设计的方法。还是需要深入理解其应用场景的。

比如两个处理手续费的服务,都要扣除同一个账户1%的手续费,这种场景,就不能留到两个服务去争抢数据库锁去处理,而是在之前就决定,只让一个服务去处理这个任务。在前面异步消息机制的时候已经提到过了如何防止重复操作,也就是实现操作幂等性。

在电商系统,分布式锁最典型的应用是在库存。前面说到,库存是需要强一致性的,同时也提到了在数据库层面如何保证库存数据的一致性。某个用户扣减库存之后,再有用户来访问到的数据一定是扣减之后的。但库存服务本身也可能是集群,多个服务同时处理用户购买同一个商品,尤其在秒杀场景。这时候,对库存的操作包括:

  1. 查询库存;
  2. 判断库存是否满足;
  3. 扣减库存.

这三个动作是要在一个原子操作里面完成的。否则,这服务刚查询的库存是10,以为满足了,结果还没扣减就被其他进程给扣减了,就形成超卖了。

要实现这个需求的原子性,一般轻易不要使用数据库的select for update来加锁,不但严重依赖于数据库,而且很容易变成全表被锁,也就是所有商品的扣减库存都串行了。这个性能就严重下降了。

我们可以用Redis的分布式锁,针对商品编号上锁,然后查询、比较、扣减,然后释放锁,唤醒其他等待服务。这样,就仅仅串行了对一个商品的扣减库存动作。如果这原子动作需要10毫秒完成,那么每秒钟最多就处理100个订单请求,而不管有多少个服务做集群了。对于一般的电商系统而言是足够了。但如果需要更多的,比如十倍、百倍以上的并发,其实就不太好处理了。就算加强数据库查询和更改的效率,减少到每次处理1毫秒,提升也是有限的,但这确实也是首要的办法。然后就是把库存分开了,一个商品分成10个库存,也就是10个独立的库存服务,也就是分成了10个锁了,基本上提升10倍。

最后呢,可以看出,锁能有效的保持服务的执行顺序,也就能保证数据的可靠性和一致性。但是锁其实是影响并发性能的,用了锁,意味着操作串行,分布式的优势就会损失。在能保证数据一致性的前提,能不用就不用的。

总结

写了这么多,也算是自己对分布式架构的理解。总结一下,我认为开发软件中比较重要几个事情:

  • 一些基本理论,很羡慕和敬仰这些国外的专家,能用我们完全看不懂的方式论证出现在让我们觉得很天经地义的道理,比如CAP,比如大数据,比如SAGA等等。做软件不能是一个单纯的堆积工具和复制黏贴,这些理论还是应该多学习。
  • 深入对业务的理解,脱离业务的架构都是耍流氓,从早年的模块划分、层次划分,到现在服务划分、服务分层,从逻辑上一个意思。我一直跟很多人强调,不要着急考虑你是不是单独部署一个服务,从逻辑上,先分析清晰,逻辑设计上,每个模块都是独立的、内聚的。是否独立成为服务,需要根据实际情况。
  • 软件开发的基本功,就是写程序,程序=数据结构+算法。不管用什么体系,最终的业务逻辑都是需要编码实现的。好的架构,保证了我们系统整体上的性能水平和稳定性水平到达一个高度,对于细节的代码级别的精妙要求其实降低了。但作为开发者,最起码要保证代码的可读性。
  • 熟悉软件设计的模式,底层的设计模式到一些类似SAGA这样的分布式事务设计模式等,这些模式都是被证明为有效的,理解这些模式,应用这些模式,能极大提高系统的优雅度。
  • 熟悉一些常用的脚手架工具,比如Spring全家桶,虽然我一直认为这个是最简单,也最不值得重视的部分,毕竟有文档,有例子,到处都有人用的工具,没理由你想用花几天还用不起来的。但是熟悉这些工具,确实能很大的提高效率。

关联文章:
软件开发随笔系列二——关于架构和模型

你可能感兴趣的:(软件开发管理)