一为什么要做分布式数据库架构改造?
云计算大数据时代,传统的数据库架构已经无法支撑企业高容量的数据增长,满足高并发的业务需求。对企业数据库进行分布式架构设计,打破了数据库资源不够用的天花板的同时,还能根据企业业务发展状况,随时平滑扩容。
二分布式数据库架构改造,如何做?
数据库分布式改造要遵循“循序渐进”的拆分原则
拆分方式有垂直拆分和水平拆分两种,选择拆分方式要根据企业自身业务发展需要。
一般来说,是先做垂直拆分,再做水平拆分。
在单一数据节点无法满足业务和用户增长需求的情况下,需要做一个服务化,对业务进行垂直梳理,后面的数据节点可以放在不同的资源节点上,以提高数据服务的整体性能。
比如一个APP的业务数据,在业务初期阶段,是全部放在一个数据库节点中,在业务量和数据量快速增长的中期阶段,需要进行垂直梳理,根据业务逻辑,拆分成商品、交易、用户,并分别放在不同的数据库。
如果其中的一个服务已经拆的很细了,但还是有性能瓶颈,无法支撑我们的业务增长,数据库这块才需要再做水平拆分。
水平拆分就是将数据(比如图中APP的交易数据)拆成多片,放到不同的资源上,用一个集群来支撑更高的业务增长。
在拆分时,要谨慎,因为拆分会引入复杂性,能不做就不做,最优先是做业务和架构上的优化,最终才是做数据库拆分。
在拆分的过程中,不要做过度的设计,或者直接从初级跳到高级,这样做其实非常浪费资源,投入产出比也不好。
三水平拆分的难点及解决方案
对企业数据库进行分布式改造,需要理解客户的业务逻辑、丰富的拆分经验积累。尤其是水平拆分,有系统复杂度高、技术挑战性强、稳定性控制难、具有一定局限性四大难点。
针对这些问题,宏翊给我们提供了两种解决方案。
1.客户端实现数据路由
此方案不会引入额外的组件,架构上比较轻量,简单场景使用尚可,但稍复杂的场景会放大它的劣势,比如配置管理复杂等。
2.数据库中间件
中间件的使用最大限度地屏蔽了分布式数据库所引入的复杂性,极大降低了研发的门槛。最重要的是,有了数据库中间件,应用看到的还是单一的数据库。
四水平切分原理及设计原则
要对一个表做拆分,选择一个拆分字段,通过一个路由算法确定数据存放在哪个底层库。
比如下列数据选择MEMBE_ID作为拆分键,通过路由算法计算后得出’test1234‘相关的数据应该落在库1上,DRDS会把所有MEMBE_ID=‘test1234’相关的请求全都路由到库1。其他数据请求亦落到相应的底层库。
接下来,当数据已经放下去了,应该如何去查询、访问和变更?
比如要查询一条记录,member_id=‘test1234’
它怎么去执行的呢?
首先计算一个hash值,当值等于某一个值,它会知道这个数据存储在哪一个库上,所以会直接路由到底层这个库,从这个库查询,返回结果。
中间件扮演的就是这个路由和计算的角色,性能非常强大。拆分后,各底层数据库数据量比较小,查询返回比较快;二是可以支持更高的并发,整体并发基本等于两个底层数据库实例并发之和。
五来自阿里云的数据库中间件产品:DRDS
数据库中间件产品中,有平民软件OneProxy等商业软件;也有MyCat等开源产品,宏翊为大家则介绍了一款广泛使用的成熟商业产品DRDS,并讲解了DRDS如何解决对数据库进行拆分时遇到的难点。
DRDS,英文名Distributed Relational Database Service
是阿里巴巴自主研发致力于解决单机数据库服务瓶颈问题而推出的分布式数据库产品。 DRDS 高度兼容 MySQL 协议和语法、支持自动化水平拆分、平滑扩容、弹性扩展、透明读写分离、分布式事务、具备分布式数据库全生命周期的运维管控能力。DRDS前身为淘宝TDDL,是近千核心应用首选组件,已稳定服务8年以上。
DRDS五大核心功能
分库分表
分库分表是DRDS的核心功能,DRDS 在后端将数据量较大的数据表水平拆分到后端的每个 RDS 数据库中,这些拆分到 RDS 中的数据库被称为分库,分库中的表称为分表。拆分后,每个分库负责每一份数据的读写操作,从而有效的分散了整体访问压力。在系统扩容时,只需要水平增加分库的数量,并且迁移相关数据,就可以提高 DRDS 系统的总体容量。DRDS 支持库级拆分,表级拆分和分库分表拆分,通过 DRDS DDL 语句指定。
读写分离
在主实例的读请求较多、读压力比较大的时候,可以通过 DRDS 读写分离功能对读流量进行分流,减轻 RDS 主实例的读压力。
DRDS 的读写分离功能是对应用透明的设计。应用在不修改任何代码的情况下,只需要在 DRDS 控制台中调整读权重,即可将读流量按配置的比例在主 RDS 实例与多个 RDS 只读实例之间进行分流;写流量则全部到主实例,不做分流。
设置读写分离后,从主 RDS 实例读取的是强读,既实时强一致读,而只读实例上的数据是从主实例上异步复制的,存在毫秒级的延迟,因此从只读 RDS 实例读取的是弱读,属于非强一致性读。个别需要实时性、强一致性读的 SQL 可以通过 DRDS Hint 指定到主实例上执行。
全局唯一ID
DRDS 支持分布式全局唯一且有序递增的数字序列。满足业务在使用分布式数据库下对主键或者唯一键以及特定场景的需求。
小表广播
DRDS 将一些数据量小且更新频度不高的数据表存储为单表模式,这些数据表称为小表。通过数据同步将小表复制到与之 JOIN 的分库上进而提升 JOIN 效率的解决方案称为“小表广播”或者“小表复制”。支持查询引擎识别和下推复杂查询,兼容 98% MySQL 语法。
弹性扩容
当逻辑库对应的底层存储已经达到物理瓶颈,需要进行水平扩展,比如磁盘余量接近30%,那么可以通过平滑扩容来改善。平滑扩容是一种水平扩容方式,既把分库平滑迁移到新添加的底层存储上。在实现上是通过增加 RDS 实例的数量来提升总体数据存储容量,将分库迁移到新增的 RDS 实例,从而降低单个 RDS 实例的处理压力。
六分布式改造之后——运维
进行分布式改造之后,如何更省心省力对数据库进行运维?
靠人工?成本高、运维人员也难招!
借助袋鼠云开发的数据库自动化管理平台EasyDB,企业数据库运维很简单。
EasyDB完全兼容DRDS manager,具有高可用、高性能、易运维等特点。从性能、资源、集群、备份、容灾入手,支持多种数据库实例,大规模量的数据库运维,提供稳定准确的数据库告警、大盘趋势分析预警、空间跟踪、SQL跟踪、巡检报告等功能。运维管理人员可以轻松应对复杂的日常管理事务及突发性事件,数据库管理从此变得有规划,有效率,有预见性。
1.基本概念
在介绍架构之前,为了避免部分读者对架构设计中的一些概念不了解,下面对几个最基础的概念进行介绍:
1 :分布式
系统中的多个模块在不同的服务器上部署,即可称为分布式系统,如Tomcat和数据库分别部署在不同的服务器上,或两个相同功能的Tomcat
分别部署在不同的服务器上。
2 :高可用
系统中部分节点失效时,其他节点能够接替它继续提供服务,则可认为系统具有高可用性。
3 :集群
一个特定领域的软件部署在多台服务器上并作为一个整体提供一类服务,这个整体称为集群。如Zookeeper中的Master和Slave分别部署在多台服务 器上,共同组成一个整体提供集中配置服务。在常见的集群中,客户端往往能够连接任意一个节点获得服务,并且当集群中一个节点掉线时,其他节点 往往能够自动的接替它继续提供服务,这时候说明集群具有高可用性。
4 :负载均衡
请求发送到系统时,通过这些方式把请求均匀分发到多个节点上,使系统中每个节点能够均匀的处理请求负载,则可认为系统是负载均衡的。
5 :正向代理和反向代理
系统内部要访问外部网络时,统一通过一个代理服务器把请求转发出去,在外部网络看来就是代理服务器发起的访问,此时代理服务器实现的是正向代理;当外部请求进入系统时,代理服务器把该请求转发到系统中的某台服务器上,对外部请求来说,与之交互的只有代理服务器,此时代理服务器
实现的是反向代理。简单来说,正向代理是代理服务器代替系统内部来访问外部网络的过程,反向代理是外部请求访问系统时通过代理服务器转发到内部服务器的过程。
2.架构演进
2.1 单机架构
以淘宝作为例子。在网站最初时,应用数量与用户数都较少,可以把Tomcat和数据库部署在同一台服务器上。浏览器往
www.taobao.com发起请求时,首先经过DNS服务器(域名系统)把域名转换为实际IP地址10.102.4.1,浏览器转而访问该
IP对应的Tomcat。
随着用户数的增长,Tomcat和数据库之间竞争资源,单机性能不足以支撑业务。
2.2 第一次演进 :Tomcat与数据库分开部署
Tomcat和数据库分别独占服务器资源,显著提高两者各自性能。
随着用户数的增长,并发读写数据库称为瓶颈
2.3 第二次演进 :引入本地缓存和分布式缓存
在Tomcat同服务器上或同JVM中增加本地缓存,并在外部增加分布式缓存,缓存热门商品信息或热门商品的html页面等。通
过缓存能把绝大多数请求在读写数据库前拦截掉,大大降低数据库压力。其中涉及的技术包括 :使用memcached作为本地缓
存,使用Redis作为分布式缓存,还会涉及缓存一致性、缓存穿透/击穿、缓存雪崩、热点数据集中失效等问题。
缓存抗住了大部分的访问请求,随着用户数的增长,并发压力主要落在单机的Tomcat上,响应逐渐变慢
2.4 第三次演进 :引入反向代理实现负载均衡
在多台服务器上分别部署Tomcat,使用反向代理软件(Nginx)把请求均匀分发到每个Tomcat中。此处假设Tomcat最多
支持100个并发,Nginx最多支持50000个并发,那么理论上Nginx把请求分发到500个Tomcat上,就能抗住50000个并发。
其中涉及的技术包括 :Nginx、HAProxy,两者都是工作在网络第七层的反向代理软件,主要支持http协议,还会涉及session
共享、文件上传下载的问题。
反向代理使得应用服务器可支持的并发量大大增加,但并发量的增长也意味着更多请求穿透到数据库,单机的数据库最终
成为瓶颈。
2.5 第四次演进 :数据库读写分离
把数据库划分为读库和写库,读库可以有多个,通过同步机制把写库的数据同步到读库,对于需要查询最新写入数据场景,可
通过在缓存中多写一份,通过缓存获得最新数据。其中涉及的技术包括:Mycat,它是数据库中间件,可通过它来组织数据库
的分离读写和分库分表,客户端通过它来访问下层数据库,还会涉及数据同步,数据一致性的问题。
业务逐渐变多,不同业务之间的访问量差距较大,不同业务直接竞争数据库,相互影响性能。
2.6 第五次演进 :数据库按业务分库
把不同业务的数据保存到不同的数据库中,使业务之间的资源竞争降低,对于访问量大的业务,可以部署更多的服务器来
支持。这样同时导致业务的表无法直接做关联分析,需要通过其他途径来解决。
随着用户数的增长,单机的写库会逐渐会达到性能瓶颈。
2.7 第六次演进 :把大表拆分为小表
比如针对评论数据,可按照商品ID进行hash,路由到对应的表中存储;针对支付记录,可按照小时创建表,每个小时表继续
拆分为小表,使用用户ID或记录编号来路由数据。只要实时操作的表数据量足够小,请求能够足够均匀的分发到多台服务器
上的小表,那数据库就能通过水平扩展的方式来提高性能。其中前面提到的MyCat也支持在大表拆分为小表情况下的访问
控制。
这种做法显著的增加了数据库运维的难度,对DBA的要求较高。数据库设计到这种结构时,已经可以称为分布式数据库,但
是这只是一个逻辑的数据库整体,数据库里不同的组成部分是由不同的组件单独来实现的,如分库分表的管理和请求分发,
由Mycat实现,SQL的解析由单机的数据库实现,读写分离可能由网关和消息队列来实现,查询结构的汇总可能由数据库接口
层来实现等等,这种架构其实是MPP(大规模并行处理)架构的一类实现。
目前开源和商用的已经有不少MPP数据库,开源中比较流行的有Greenplum、TiDB、Postgresql XC、HAWQ等,商用的如
南大通用的GBase、睿帆科技的雪球DB、华为的LibrA等等,不同的MPP数据库的侧重点也不一样,如TiDB更侧重于分布式
OLTP场景,Greenplum更侧重于分布式OLAP场景,这些MPP数据库基本都提供了类似Postgresql、Oracle、MySQL那样
的SQL标准支持能力,能把一个查询解析为分布式的执行计划分发到每台机器上并行执行,最终由数据库本身汇总数据进行
返回,也提供了诸如权限管理、分库分表、事物、数据副本等能力,并且大多能够支持100个节点以上的集群,大大降低了
数据库运维的成本,并且使数据库也能够实现水平扩展。
数据库和Tomcat都能够水平扩展,可支撑的并发大幅提高,随着用户数的增长,最终单机的Nginx会成为瓶颈。
2.8 第七次演进 :使用LVS或F5来使多个Nginx负载均衡
由于瓶颈在Nginx,因此无法通过两层的Nginx来实现多个Nginx的负载均衡。图中的LVS和F5是工作在网络第四层的负载均
衡解决方案,其中LVS是软件,运行在操作系统内核态,可对TCP请求或更高层级的网络协议进行转发,因此支持的协议
更丰富,并且性能也远高于Nginx,可假设单机的LVS可支持几十万个并发的请求转发;F5是一种负载均衡硬件,与LVS提
供的能力类似,性能比LVS更高,但价格昂贵。由于LVS是单机版的软件,若LVS所在服务器宕机则会导致整个后端系统都
无法访问,因此需要有备用节点。可使用keepalived软件模拟出虚拟IP,然后把虚拟IP绑定到多台LVS服务器上,浏览器
访问虚拟IP时,会被路由器重定向到真实的LVS服务器,当主LVS服务器宕机时,keepalived软件会自动更新路由器中的
路由表,把虚拟IP重定向到另外一台正常的LVS服务器,从而达到LVS服务器高可用的效果。
此处需要注意的是,上图中从Nginx层到Tomcat层这样画并不代表全部Nginx都转发请求到全部的Tomcat,在实际使用时,
可能会是几个Nginx下面接一部分的Tomcat,这些Nginx之间通过keepalived实现高可用,其他的Nginx接另外的Tomcat,
这样可接入的Tomcat数量就能成倍的增加。
由于LVS也是单机的,随着并发数增长到几十万时,LVS服务器最终会达到瓶颈,此时用户数达到千万甚至上亿级别,
用户分布在不同的地区,与服务器机房距离不同,导致了访问的延迟会明显不同。
2.9 第八次演进 :通过DNS轮询实现机房间的负载均衡
在DNS服务器中可配置一个域名对应多个IP地址,每个IP地址对应到不同的机房里的虚拟IP。当用户访问www.taobao.com
时,DNS服务器会使用轮询策略或其他策略,来选择每个IP供用户访问。此方式能实现机房间的负载均衡,至此,系统可
做到机房级别的水平扩展,千万级到亿级的并发量都可通过增加机房来解决,系统入口处的请求并发量不再是问题。
随着数据的丰富程度和业务的发展,检索、分析等需求越来越丰富,单单依靠数据库无法解决如此丰富的需求。
2.10 第九次演进 :引入NoSQL数据库和搜索引擎等技术
当数据库中的数据多到一定规模时,数据库就不适用于复杂的查询了,往往只能满足普通查询的场景。对于统计报表场景,在
数据量大时不一定能跑出结果,而且在跑复杂查询时会导致其他查询变慢,对于全文检索、可变数据结构等场景,数据库天
生不适用。因此需要针对特定的场景,引入合适的解决方案。如对于海量文件存储,可通过分布式文件系统HDFS解决,对于
key value类型的数据,可通过HBase和Redis等方案解决,对于全文检索场景,可通过搜索引擎如ElasticSearch解决,对于
多维分析场景,可通过Kylin或Druid等方案解决。
当然,引入更多组件同时会提高系统的复杂度,不同的组件保存的数据需要同步,需要考虑一致性的问题,需要有更多的运维手段来管理这些组件等。
引入更多组件解决了丰富的需求,业务维度能够极大扩充,随之而来的是一个应用中包含了太多的业务代码,业务的升级
迭代变得困难。
2.11 第十次演进 :大应用拆分为小应用
按照业务板块来划分应用代码,使单个应用的职责更清晰,相互之间可以做到独立升级迭代。这时候应用之间可能会涉及到
一些公共配置,可以通过分布式配置中心Zookeeper来解决。
不同应用之间存在共用的模块,由应用单独管理会导致相同代码存在多份,导致公共功能升级时全部应用代码都要跟着升级
。
2.12 第十一次演进 :复用的功能抽离成微服务
如用户管理、订单、支付、鉴权等功能在多个应用中都存在,那么可以把这些功能的代码单独抽取出来形成一个单独的服务
来管理,这样的服务就是所谓的微服务,应用和服务之间通过HTTP、TCP或RPC请求等多种方式来访问公共服务,每个单独服务都可以由单独的团队来管理。此外,可以通过Dubbo、SpringCloud等框架实现服务治理、限流、熔断、降级等功能,提高
服务的稳定性和可用性。
不同服务的接口访问方式不同,应用代码需要适配多种访问方式才能使用服务,此外,应用访问服务,服务之间也可能相互
访问,调用链将会变得非常复杂,逻辑变得混乱。
2.13 第十二次演进 :引入企业服务总线ESB屏蔽服务接口的访问差异
通过ESB统一进行访问协议转换,应用统一通过ESB来访问后端服务,服务与服务之间也通过ESB来相互调用,以此降低系统
的耦合程度。这种单个应用拆分为多个应用,公共服务单独抽取出来来管理,并使用企业消息总线来解除服务之间耦合问题的
架构,就是所谓的SOA(面向服务)架构,这种架构与微服务架构容易混淆,因为表现形式十分相似。个人理解,微服务架构
更多的是指把系统里的公共服务抽取出来单独运维管理的思想,而SOA架构则是指一种拆分服务并使服务接口访问变得统一
的架构思想,SOA架构中包含了微服务的思想。
业务不断发展,应用和服务都会不断变多,应用和服务的部署变得复杂,同一台服务器上部署多个服务还要解决运行环境冲突的问题,此外,对于如大促这些需要动态扩缩容的场景,需要水平扩展服务的性能,就需要在新增的服务上准备运行环境,部署
服务等,运维将变得十分困难。
2.14 第十三次演进 :引入容器化技术实现运行环境隔离与动态服务管理
目前最流行的容器化技术是Docker,最流行的容器管理服务是Kubernetes(K8S),应用/服务可以打包为Docker镜像,通过
K8S来动态分发和部署镜像。Docker镜像可理解为一个能运行你的应用/服务的最小的操作系统,里面放着应用/服务的
运行代码,运行环境根据实际的需要设置好。把整个“操作系统”打包为一个镜像后,就可以分发到需要部署相关服务的
机器上,直接启动Docker镜像就可以把服务运行起来,使服务的部署和运维变得简单。
在大促的之前,可以在现有的机器集群上划分出服务器来启动Docker镜像,增强服务的性能,大促过后就可以关闭镜像,
对集群上的其他服务不造成影响(3.14节之前,服务运行在新增机器上需要修改系统配置来适配服务,这会导致机器上
其他服务需要的运行环境被破坏)
使用容器化技术后服务动态扩缩容问题得以解决,但是机器换还是需要公司自身来管理,在非大促的时候,还是需要闲置
着大量的机器资源来应对大促,机器自身成本和运维成本都极高,资源利用率低。
3.15 第十四次演进 :以云平台承载系统
系统可部署到公有云上,利用公有云的海量机器资源,解决动态硬件资源的问题,在大促的时间段里,在云平台中临时申请更
多的资源,结合Docker和K8S来快速部署服务,在大促结束后释放资源,真正做到按需付费,资源利用率大大提高,同时大
大降低了运维成本。
所谓的云平台,就是把海量机器资源,通过统一的资源管理,抽象为一个资源整体,在之上可按需动态申请硬件资源(如CPU
、内存、网络等),并且之上提供通用的操作系统,提供常用的技术组件(如Hadoop技术栈,MPP数据库等)供用户使用
,甚至提供开发好的应用,用户不需要关系应用内部使用了什么技术,就能够解决需求(如音频转码服务、邮件服务、个人博客等)。在云平台中会涉及如下几个概念 :
1 :IaaS :基础设施即服务。对应于上面所说的机器资源统一为资源整体,可动态申请硬件资源的层面;
2 :PaaS :平台即服务。对应于上面所说的提供常用的技术组件方便系统的开发和维护;
3 :SaaS :软件即服务。对应于上面所说的提供开发好的应用或服务,按功能或性能要求付费。
至此,以上所提到的从高并发访问问题,到服务的架构和系统实施的层面都有了各自的解决方案,但同时也应该意识到,在
上面的介绍中,其实是有意忽略了诸如跨机房数据同步、分布式事物实现等等的实际问题,这些问题以后再单独讨论。
3. 架构设计总结
架构的调整是否必须按照上述演变路径进行?
不是的,以上所说的架构演进顺序只是针对某个侧面进行单独的改进,在实际场景中,可能同一时间会有几个问题需要解决,或者可能
先达到瓶颈的是另外的方面,这时候就应该按照实际问题实际解决。如在政府类的并发量可能不大,但业务可能很丰富的场景,高并发
就不是重点解决的问题,此时优先需要的可能会是丰富需求的解决方案。
对于将要实施的系统,架构应该设计到什么程度?
对于单次实施并且性能指标明确的系统,架构设计到能够支持系统的性能指标要求就足够了,但要留有扩展架构的接口以便不备之需。
对于不断发展的系统,如电商平台,应设计到满足下一阶段用户量和性能指标要求的程度,并根据业务的增长不断的迭代升级架构,
以支持更高的并发和更丰富的业务。
服务端架构和大数据架构有什么区别?
所谓的“大数据”其实是海量数据采集清洗转换、数据存储、数据分析、数据服务等场景解决方案的一个统称,在每一个场景都包含了
多种可选的技术,如数据采集有Flume、Sqoop、Kettle等,数据存储有分布式文件系统HDFS、FastDFS、NoSQL数据库HBase、
MongoDB等,数据分析有Spark技术栈、机器学习算法等。总的来说大数据架构就是根据业务的需求,整合各种大数据组件组合而
成的架构,一般会提供分布式存储、分布式计算、多维分析、数据仓库、机器学习算法等能力。而服务端架构更多指的是应用组织
层面的架构,底层能力往往是由大数据架构来提供。
有没有 一些架构设计的原则?
1 :N + 1设计。系统中的每个组件都应做到没有单点故障;
2 :回滚设计。确保系统可以向前兼容,在系统升级时应能有办法回滚版本;
3 :禁用设计。应该提供控制具体功能是否可用的配置,在系统出现故障时能够快速下线功能;
4 :监控设计。在设计阶段就要考虑监控的手段;
5 :多活数据中心设计。若系统需要极高的高可用,应考虑在多地实施数据中心进行多活,至少在一个机房断电的情况下系统依然可用;
6 :采用成熟的技术。刚开服的或开源的技术往往存在很多隐藏的bug,出了问题没有商业支持可能会是一个灾难;
7 :资源隔离技术。应避免单一业务占用全部资源;
8 :架构应能水平扩展。系统只有做到能水平扩展,才能有效避免瓶颈问题;
9 :非核心则购买。非核心功能若需要占用大量的研发资源才能解决,则考虑购买成熟的产品;
10 :使用商用硬件。商用硬件能有效降低硬件故障的机率;
11 :快速迭代。系统应该快速开发小功能模块,尽快上线进行验证,早日发现问题大大降低系统交付的风险;
12 :无状态设计。服务接口应该做成无状态的,当前接口的访问不依赖于接口上次访问的状态。
1 双重写入的问题
关于你是否会面临双重写入的问题有一个简单的指标,那就是预期要不要向多个记录系统进行写入操作。这样的需求可能并不明显,在分布式系统设计的过程中,它可能会以不同的方式进行表述。比如说:
你已经为每项工作选择了最佳工具,现在在一个业务事务中,你必须要更新一个 NoSQL 数据库、一个搜索索引和一个缓存。
你所设计的服务必须要更新自己的数据库,同时还要把变更相关的信息以通知的形式发送给另一个服务。
你的业务事务跨越了多个服务的边界。
你可能需要以幂等的方式实现服务操作,因为服务的消费者必须要重试失败的调用。
在本文中,我们将会使用一个很简单的示例场景来评估在分布式事务中处理双重写入的各种方法。我们的场景是一个客户端应用,它会在发生变更操作的时候,调用一个微服务。服务 A 要更新自己的数据库,但是它还要调用服务 B 进行写入操作,如图 1 所示。至于数据库的实际类型以及服务与服务之间进行交互的协议,这些对于我们的讨论都无关紧要,因为问题都是一样的。
微服务中的双重写入问题
我们简要解释一下为什么这个问题没有简单的解决方案。如果服务 A 写入到了自己的数据库,然后发送一个通知到队列中供服务 B 使用(我们将这种方式称为 local-commit-then-publish),这样应用依然有可能无法可靠地运行。
当服务 A 写入到自己的数据库,然后发送消息到队列时,依然有很小的概率发生这样的事情,即应用在提交到数据库后,且在第二个操作之前,发生了崩溃,这样的话,就会使系统处于一个不一致的状态。如果消息在写入到数据库之前发送的话(我们将这种方式称为 publish-then-local-commit),有可能出现数据库写入失败,或者服务 B 接收到事件的时候,服务 A 还没有提交到数据库,这会出现时效性问题。不管是出现哪种情况,这种场景都会涉及到对数据库和队列的双重写入问题,这就是我们要探讨的核心问题。在下面的章节中,我们将会讨论针对这一长期存在的挑战目前已有的各种解决方案。
2 模块化单体
将应用程序开发为模块化单体看起来像一种权宜之计(hack),或是架构演化的一种倒退。但是,我发现它在实践中能够很好地运行。它不是一种微服务的模式,而是微服务规则的一个例外情况,能够非常严谨地与微服务相结合。如果强写入一致性是驱动性的需求,甚至要比独立部署和扩展微服务的能力更重要时,那么我们就可以采用模块化单体的架构。
采用单体架构并不意味着系统设计得很差或者是件坏事。它并不说明任何质量相关的问题。顾名思义,这是一个按照模块化方式设计的系统,它只有一个部署单元。需要注意,这是一个精心设计和实现的模块化单体,这与随意创建并随时间而不断增长的单体是不同的。在精心设计的模块化单体架构中,每个模块都遵循微服务的原则。每个模块会封装对其数据的所有访问,但是操作是以内存方法调用的方式进行暴露和消费的。
模块化单体的架构
如果采用这种方式的话,我们必须要将两个微服务(服务 A 和服务 B)转换成可以部署到共享运行时的库模块(library module)。然后,让这两个微服务共享同一个数据库实例。因为服务是在一个通用的运行中编写和部署的,所以它们可以参与相同的事务。鉴于这些模块共享同一个数据库实例,所以我们可以使用本地事务一次性地提交或回滚所有的变更。在部署方法方面也有差异,因为我们希望模块以库的方式部署到一个更大的部署单元中,并参与现有的事务。
即便是在单体架构中,也有一些方式来隔离代码和数据。例如,我们可以将模块隔离成单独的包、构建模块和源码仓库,这些模块可以由不同的团队所拥有。通过将表按照命名规则、模式、数据库实例,甚至数据库服务器的方式进行分组,我们可以实现数据的部分隔离。图 2 的灵感来源于 Axel Fontaine 关于伟大的模块化单体的演讲,它阐述了应用中不同的代码和数据隔离级别。
应用程序的代码和数据隔离级别
拼图的最后一块是使用一个运行时和一个包装器服务(wrapper service),该服务能够消费其他的模块并将其纳入到现有事务的上下文中。所有的这些限制使模块比典型的微服务耦合更紧密,但是好处在于包装器服务能够启动一个事务、调用库模块来更新它们的数据库,并且以一个操作的形式提交或回滚事务,而不必担心部分失败或最终一致性的问题。
在我们的样例中,如图 3 所示,我们将服务 A 和服务 B 转换为库,并将它们部署到一个共享的运行时中,或者也可以将其中的某个服务作为共享运行时。数据库的表也共享同一个数据库实例,但是它会被拆分为一组由各自的库服务管理的表。
具有共享数据库的模块化单体
模块化单体的优点和缺点
在有些行业中,这种架构的收益远比其他地方所看重的更快的交付以及更快的变更节奏重要得多。表 1 总结了模块化单体架构的优点和缺点。
表 1:模块化单体架构的优点和缺点
分布式事务通常是最后的方案,通常会在如下的情况下使用:
当对不同资源的写入操作不允许最终一致性时;
当我们必须要写入到不同种类的数据源时;
当我们需要确保对消息的处理有且仅有一次,而且无法重构系统以实现操作的幂等性时;
当与第三方黑盒系统或实现了两阶段提交规范的遗留系统进行集成时。
在这些情况下,如果可扩展性不是重要的关注点的话,我们可以考虑将分布式事务作为一种可选方案。
实现两阶段提交架构
两阶段提交技术要求我们有一个分布式事务管理器(如 Narayana)和一个可靠的事务日志存储层。我们还需要能够兼容 DTP XA 的数据源,以及能够参与分布式事务的相关的 XA 驱动,比如 RDBMS、消息代理和缓存。如果你足够幸运有合适的数据源,但是运行在一个动态环境中,比如 Kubernetes,那么你还需要有一个像 operator 这样的机制,以确保分布式事务管理器只有一个实例。事务管理器必须是高可用的,并且必须能够访问事务日志。
就实现而言,你可以尝试使用 Snowdrop Recovery Controller,它使用 Kubernetes StatefulSet 模式来实现单例,并使用持久化卷来存储事务日志。在这个类别中,我还包含了适用于 SOAP Web 服务的 Web Services Atomic Transaction(WS-AtomicTransaction)等规范。所有这些技术的共同点在于它们实现了 XA 规范,并且有一个中心化的事务协调器。
在我们的样例中,如图 4 所示,服务 A 使用分布式事务提交所有的变更到自己的数据库中,并且会提交一条消息到队列中,这个过程中不会出现消息的重复和丢失。类似的,服务 B 可以使用分布式服务来消费消息,并在同一个事务中提交至数据库 B,这个过程中也不会出现任何的重复数据。或者,服务 B 也可以选择不使用分布式事务,而是使用本地事务并实现幂等的消费者模式。在本节中,一个更合适的例子是使用 WS-AtomicTransaction 在一个事务中协调对数据库 A 和数据库 B 的写入,并完全避免最终一致性。但是,现在这种方式已经不太常见了。
跨数据库和消息代理的二阶段提交
两阶段提交架构优点和缺点
两阶段提交协议所提供的保障与模块化单体中的本地事务类似,但有些例外情况。因为这里有两个或更多的独立数据源参与到原子更新之中,所以它们可能会以不同的方式失败并阻塞整个事务。但是,由于存在一个中心化的协调者,相对于我下面将要讨论的其他方式,我们还是能够很容易地发现分布式系统的状态。
表 2:两阶段提交的优点和缺点
3 编排式
对于模块化单体来讲,我们会使用本地事务,这样我们始终能够知道系统的状态。对基于两阶段提交的分布式事务,我们也能保证状态的一致性。唯一的例外情况是事务协调者出现了不可恢复的故障。但是,如果我们想要减弱一致性的需求,而希望能够了解整个分布式系统的状态,并且能从一个地方对其进行协调,那么我们该怎么处理呢?
在这种情况下,我们可以考虑采取一种编排(orchestration)的方式,在这里,某个服务会担任整个分布式状态变更的协调者和编排者。编排者服务有责任调用其他的服务,直至它们达到所需的状态,或者在它们出现故障的时候执行纠正措施。编排者使用它的本地数据库来跟踪状态变更,并且要负责恢复与状态变更的所有故障。
实现编排式架构
编排式技术最流行的实现是 BPMN 规范的各种具体实现,比如 jBPM 和 Camunda。对这种系统的需求并不会因为微服务或 Serverless 这样的极度分布式架构的出现而消失,相反,这种需求还会增加。为了证明这一点,我们可以看一下较新的有状态编排引擎,它们没有遵循什么规范,但是却提供了类似的有状态行为,比如 Netflix 的 Conductor、Uber 的 Cadence 和 Apache 的 Airflow。像 Amazon StepFunctions、Azure Durable Functions 和 Azure Logic Apps 这样的 Serverless 有状态函数也属于这个类别。还有一些开源库允许我们实现有状态的协调和回滚行为,如 Apache Camel 的 Saga 模式实现和 NServiceBus 的 Saga 功能。许多实现 Saga 模式的自定义系统也属于这一类。
编排两个服务的分布式事务
在我们的示例图中,我们让服务 A 作为有状态的编排者,负责调用服务 B 并在需要的时候通过补偿操作从故障中恢复。这种方式的关键特征是,服务 A 和服务 B 有本地事务的边界,但是服务 A 有协调整个交互流程的知识和责任。这也是为什么它的事务边界会接触到服务 B 的端点。在实现方面,我们可以使用同步的交互,就像上图所示,也可以在服务之间使用消息队列(在这种情况下我们也可以使用两阶段提交)。
编排式的优点和缺点
编排式是一种最终一致的方法,它可能会涉及到重试和回滚才能使分布式系统达到一致的状态。虽然避免了对分布式事务的需求,但是编排的方式要求参与的服务提供幂等的操作,以防协调者必须进行重试操作。参与的服务还必须要提供恢复端点,以防协调者决定执行回滚并修复全局状态。这种方式的最大优点是,能够仅通过本地事务就能驱动那些可能不支持分布式事务的异构服务达到一致的状态。协调者和参与的服务只需要本地事务即可,而且始终能够通过协调者查询系统的状态,即便它目前可能处于部分一致的状态。在下面我所描述的其他方式中,是不可能实现这一点的。
表 3:编排式的优点和缺点
4 协同式
从迄今为止的讨论中,我们可以看到,一个业务操作可能会导致服务间的多次调用,并且一个业务事务完成端到端的处理所需的时间是不确定的。为了管理这一点,编排式(orchestration)模式会使用一个中心化的控制器服务,它会告诉参与者该做什么。
编排式的一种替代方案就是协同式(choreography),在这种风格的服务协调中,参与者在交换事件时没有一个中心化的控制点。在这种模式下,每个服务会执行一个本地事务并发布事件,从而触发其他服务中的本地事务。系统中的每个组件都要参与业务事务工作流的决策,而不是依赖一个中心化的控制点。在历史上,协同式方式最常见的实现就是使用异步消息层来进行服务的交互。图 6 说明了协同式模式的基本架构。
通过消息层进行服务协同化
具有双重写入的协同式
为了实现基于消息的服务协同,我们需要每个参与的服务执行一个本地事务,并通过向消息基础设施发布一个命令或事件,以触发下一个服务。同样的,其他参与的服务必须消费一个消息并执行本地事务。从本质上来讲,这就是在一个较高层级的双重写入问题中又出现了另一个双重写入的问题。当我们开发一个具有双重写入的消息层来实现协同式模式的时候,我们可以把它设计成跨本地数据库和消息代理的一个两阶段提交。在前面,我们曾经介绍过这种方式。另外,我们也可以采用 publish-then-local-commit 或 local-commit-then-publish 模式:
Publish-then-local-commit:我们可以先尝试发布一条消息,然后再提交本地事务。虽然这种方案听起来不错,但是它有一些切实的挑战。举例来说,在很多时候,我们需要发布一个由本地事务所生成的 ID,而这个 ID 此时还没有生成,因此无法发布。另外,本地事务有可能会失败,但是我们无法回滚已经发布的消息。这种方式缺乏“读取自己的写入”的语义,因此对于大多数场景来说,这并不是合适的方案。
Local-commit-then-publish:一个稍好一点的办法是先提交本地事务,然后再发布消息。在本地事务提交之后和消息发布之前这里有很小的概率会出现故障。但即便是出现这样的情况,你也可以把服务设计成幂等的并对操作进行重试。这意味着会再次提交本地事务并发布消息。如果你能控制下游的消费者并且确保它们是幂等的,那么这种方式就是行之有效的。总体而言,这是一个很好的实现方案。
无双重写入的协同式
实现协同式架构的各种实现方式都限制每个服务都要通过本地事务写入到单一的数据源中,而不能写入到其他的地方中。我们看一下,如何在避免双重写入的情况下实现这一点。
假设服务 A 接收到一个请求并要对数据库 A 进行写入操作,除此之外不再操作其他的数据源。服务 B 周期性地轮询服务 A 并探测新的变更。当它读取到变更时,服务 B 会基于变更更新自己的数据库,并且会更新索引或时间戳来标记获取到了变更。这里的关键在于,这两个服务只对自己的数据库进行写入操作,并以本地事务的形式进行提交。如图 7 所示,这种方式可以描述为服务协同(service choreography),或者我们也可以用非常古老的数据管道的术语来对其进行描述。至于可供选用的实现方案就更有趣了。
通过轮询实现的服务协同
对于服务 B 来说,最简单的场景就是连接到服务 A 的数据库并读取服务 A 的表。但是,业界会尽量避免共享数据表这种级别的耦合,原因在于:服务 A 的实现和数据模型的任意变更都可能干扰到服务 B。我们可以对这种场景做一些改进,例如使用发件箱(Outbox)模式,为服务 A 提供一个表作为公开接口。这个表可以只包含服务 B 所需的内容,它可以设计得易于查询和跟踪变更。如果你觉得这还不够好的话,进一步的改进方案是让服务 B 通过 API 管理层查询服务 A 的所有变化,而不是直接连接数据库 A。
从根本上来讲,所有的这些变种形式都有一个相同的缺点:服务 B 需要不断地轮询服务 A。这种方式会给系统带来不必要的持续负载,或者在接收变更时存在不必要的延迟。轮询微服务的变更并不是常见的做法,那么我们看一下如何进一步改善这个架构。
使用 Debezium 的协同式
在改进协同式架构时,有一种方式很有吸引力,那就是引入像 Debezium 这样的工具,它使用数据库 A 的事务日志执行变更数据捕获(change data capture,CDC)。这种方式如图 8 所示。
通过变更数据捕获实现的服务协同
Debezium 可以监控数据库的事务日志,执行必要的过滤和转换,并将相关的变更投递到 Apache Kafka 的主题中。这样的话,服务 B 就可以监听主题中的通用事件,而不是轮询服务 A 的数据库或 API。我们通过这种方式,将数据库轮询转换成了流式变更,并且在服务间引入了一个队列,这样会使得分布式系统更加可靠、可扩展,而且为新的使用场景会引入其他消费者提供了可能性。Debezium 提供了一种优雅的方式来实现发件箱模式,能够用于基于编排式和协同式的 Saga 模式实现。
这种方式的一个副作用在于,服务 B 有接收到重复消息的可能性。这可以通过实现幂等的服务来解决,可以在业务逻辑层面来解决,也可以使用技术化的去重器(deduplicator,比如 Apache ActiveMQ Artemis 的重复消息探测或者 Apache Camel 的幂等消费者模式)。
使用事件溯源的协同式模式
事件溯源(event sourcing)是另外一种服务协同的实现模式。在这种模式下,实体的状态会被存储为一系列的状态变更事件。当有新的更新时,不是更新实体的状态,而是往事件的列表中追加一个新的事件。往事件存储中追加新的事件是一个原子性的操作,会在一个本地事务中完成。如图 9 所示,这种方式的好处在于,对于消费数据更新的其他服务来讲,事件存储的行为也是一个消息队列。
通过事件溯源实现的服务协同
在我们样例中,如果要转换成使用事件溯源的话,要把客户端的请求存储在一个只能进行追加操作的事件存储中。服务 A 可以通过重放(replay)事件重新构建当前的状态。事件存储需要让服务 B 也订阅相同的更新事件。通过这种机制,服务 A 使用其存储层作为与其他服务的通信层。尽管这种机制非常整洁,解决了当有状态变更时可靠地发布事件的问题,但是它引入了一种很多开发人员所不熟悉的编程风格,并且围绕状态重建和消息压缩,会引入额外的复杂性,这需要专门的存储。
协同式的优点和缺点
不管使用哪种方式来检索数据变更,协同式的模式都解耦了写入,能够实现独立的服务可扩展性,并提升系统整体的弹性。这种方式的缺点在于,决策流是分散的,很难发现全局的分布式状态。要查看一个请求的状态需要查询多个数据源,这对于服务数量众多的场景来说是一个挑战。表 4 总结了这种方式的优点和缺点。
表 4:协同式的优点和缺点
5 并行管道
在协同式模式中,没有一个中心化的地方可以查询系统的状态,但是会有一个服务的序列,以便于在分布式系统中传播状态。协同式模式创建了一个处理服务的序列化管道,所以我们能够知道当一个消息到达整个过程的特定步骤时,它肯定已经通过了前面的所有步骤。如果我们能够放松这个限制,允许独立地处理这些步骤的话,情况又会怎样呢?在这种场景下,服务 B 在处理一个请求的时候,根本不用关心服务 A 是否已经处理过它。
在并行管道的方式中,我们会添加一个路由服务,该服务接收请求,并在一个本地事务中通过消息代理将请求转发至服务 A 和服务 B。如图 10 所示,从这个步骤开始,两个服务可以独立、并行地处理请求。
通过并行管道进行处理
尽管这种模式很容易实现,但是它只适用于服务之间没有时间约束的场景。例如,服务 B 不管服务 A 是否已经处理过该请求,它都能够对请求进行处理。同时,这种方式需要一个额外的路由服务,或者客户端知道服务 A 和服务 B,从而能够给它们发送消息。
监听自身
这种方式有一种轻量级的替代方案,被称为“监听自身(listen to yourself)”模式,在这里,其中有个服务会同时担任路由。在这种替代方式下,当服务 A 接收到一个请求时,它不会写入到自己的数据库中,而是将请求发送至消息系统中,而消息的目标是服务 B 以及服务 A 本身。图 11 阐述了这种模式。
监听自身模式
在这里,不写入数据库的原因在于避免双重写入。当进入消息系统之后,消息会在完全独立的事务上下文中进入服务 B,也会重新返回服务 A。通过这样一个曲折的处理流程,服务 A 和服务 B 就可以独立地处理请求,并写入到各自的数据库中了。
并行管道的优点和缺点
表 5:并行管道的优点和缺点
6 如何选择分布式事务策略
从本文的论述中,你可能已经猜到,在微服务架构中,处理分布式事务并没有正确或错误的模式。每种模式都有其优点和缺点。每种模式都能解决一些问题,但是反过来又会产生其他的问题。图 12 中的图表简单总结了我所阐述的各种双重写入模式的主要特征。
双重写入模式的特征
不管你采用哪种方式,都要阐述和记录决策背后的动机,以及该选择在架构上所带来的长期影响。你还需要得到从长期实现和维护该系统的团队那里获取支持。在这里,我根据数据一致性和可扩展性特征来组织和评估本文所描述的各种方法,如图 13 所示。
各个双重写入模式的数据一致性和可扩展性特征
我们从可扩展性最强、可用性最高的方法到可扩展性最差、可用性最低的顺序来评估各种方法。
高:并行管道和协同式
如果你的步骤在时间上是解耦的,那么采用并行管道的方法来运行是很合适的。有可能你只能在系统的某些部分使用这种模式,而不是在整个系统中。接下来,假设步骤间存在时间方面的耦合性,特定的操作和服务必须要在其他的服务前执行,那么你可以考虑采用协同式的方式。借助协同式的服务,我们可以创建一个可扩展的、事件驱动的架构,在这里消息会通过一个去中心化的协同化过程在服务和服务之间流动。在这种情况下,使用 Debezium 和 Apache Kafka 的发件箱模式实现(如 Red Hat OpenShift Streams for Apache Kafka)特别有趣,而且越来越受欢迎
中等:编排式和两阶段提交
如果协同式模式不是很合适,你需要一个负责协调和决策的中心点,那么可以考虑采用编排式模式。这是一个流行的架构,有基于标准的和自定义的开源实现。基于标准的实现可能会强迫你使用某些事务语义,而自定义的编排式实现则允许你在所需的数据一致性和可扩展性之间进行权衡。
低:模块化单体
如果你沿着图示再往左走的话,那么很可能你对数据一致性有非常强烈的需求,而且对它所需的重大权衡有充分的思想准备。在这种情况下,针对特定数据源,通过两阶段提交的分布式事务是可行的,但是在专门为可扩展性和高度可用性设计的动态云环境中,它很难可靠地实现。如果是这样的话,那么你可以直接采用比较老式的模块化单体方式,同时伴以从微服务运动中学到的实践。这种方式可以确保最高的数据一致性,但代价是运行时和数据源的耦合。
7 结论
在具有数十个服务的大型分布式系统中,并不会有一个适用于所有场景的方式,我们需要将其中的几个方法结合起来,应用于不同的环境中。我们可能会将几个服务部署在一个共享的运行时上,以满足对数据一致性的特殊需求。我们可能会选择两阶段的提交来与支持 JTA 的遗留系统进行集成。我们可能会编排复杂的业务流程,并让其余的服务使用协同式模式和并行处理。总而言之,你选择什么策略并不重要,重要的是基于正确的原因,精心选择一个策略,并执行它。
这篇文章长度过长,我就罗列到这里,需要分布式服务架构框原理与实践和面试真题答案PDF完整版笔记资料的朋友可以:点赞+转发+关注!后台小信封直接回复【444】即可获取!