【架构演进】?XX系统架构演进的思考(一)

  1. 交易系统架构演进之路(一):1.0版
  2. 交易系统架构演进之路(二):2.0版
  3. 交易系统架构演进之路(三):微服务化
  4. 交易系统架构演进之路(四):分布式事务
  5. 交易系统架构演进之路(五):服务治理
  6. 交易系统架构演进之路(六):容器化
  7. 交易系统架构演进之路(七):Service Mesh

前言

虽然我是在聊交易平台的架构设计,但背后的本质,更多其实是想传达更普适的架构思想。比如:

  • 我们应该由场景驱动架构,做架构之前要先充分理解需求
  • 不要过度设计,但可以适度超前
  • 能用简单方案满足当前需求,就不要考虑复杂方案
  • 架构就是在各种选择中做平衡

需求分析

任何架构的演进都是由场景驱动的,离开场景谈架构就是耍流氓。因此,做架构设计之前,我们要先了解当下的场景。场景就是需求,一般可以将需求分为三类:商业需求、功能需求和质量需求

  • 商业需求:是最高层次的需求,它关注从客户群、企业现状、未来发展、预算、立项、开发、运营、维护在内的整个软件生命周期涉及的商业因素,包括了商业层面的目标、期望和限制等。商业需求一般对架构的影响比较大,对架构产生限制的商业因素也比较多,比较常见的包括:上市时间、成本预算、人力现状、目标市场、阶段性计划、国际化等等。
  • 功能需求:描述的就是系统应该提供的服务,包括为用户提供的服务,也包括为其他系统提供的服务,比如开放 API。
  • 质量需求:则是技术上的需求了,在三类需求中,一般其需求层次也是最低的,但却是大部分架构师最关注的。质量需求涉及到的属性也比较多,关注最多的比如可用性、性能、安全性、扩展性等等。

对于一个从 0 到 1 的产品来说,大部分情况最受限制的就是成本预算,因此,能投入的人力也比较少,而且上市时间拖得越久成本就越高,所以上市时间也是越快越好。总的来说,产品第一个阶段的核心需求就是,要用最低的成本、最快的速度,设计研发完成产品,并推向市场。要满足此需求,我们只需要开发出一个 MVP(最小化可行产品) 即可。所谓 MVP,就是只要能让用户完成最简化的核心流程即可,不需要太多考虑优化流程、更好的用户体验等。

架构设计

需求分析完成之后,就可以进入架构设计了。对于 MVP 版本来说,最重要的就是简单快速实现需求并上线,那就没必要考虑 SOA、微服务等分布式架构,就直接用单体架构最合适。但并不是说用单体架构就可以不用做架构设计了,单体也只是服务端用单体而已,但整个交易系统并不只有服务端,还包括客户端和数据库,而且单体内部又如何组织,这也是需要设计的。而且,架构师还应该具备一定的前瞻性,要考虑到后续的业务发展,要有适度超前的设计思维,从而设计出能满足当前场景需求的系统,并具备扩展性,能快速实现满足下一阶段的业务需求,以及能方便快捷地进行架构演进。接着,就来说说我对当前版本的设计思路。

先从整体来考虑:

  • 首先,是否要前后端分离的话,API 要怎么设计li>
  • 其次,数据库的选型,是用传统的关系型数据库(也称为 OldSQL),还是 NoSQl,抑或 NewSQL库的表又应该如何设计li>
  • 最后,服务端的代码应该如何组织,单体内部采用什么架构模式。

先说第一个问题,是否要前后端分离是肯定要分离的,虽然第一版只需要支持 Web 端,但后续肯定还要支持移动端 App,甚至支持桌面客户端,不做前后端分离的话就很难做到多端支持。既然前后端是分离的,那就需要对客户端与服务端之间交互的 API 进行设计,包括使用什么通讯协议、数据传输协议、安全机制等。

数据库设计思考

设计关系数据库时,可以尽量遵循一些设计规范,这些规范也称为范式,从而设计出结构合理、冗余较小的数据库。不过,在实际设计的时候,其实也不是必须完全遵循这些范式,只是尽量遵循,但对于一些特殊情况,是可以有一些妥协的,比如,在一张表中增加冗余字段可能不符合范式,但可以提高查询效率。不过,有一条原则需要遵循:选择作为冗余的字段应不需要额外的工作来保持数据一致性。比如用户昵称,这是用户可以随时修改的,就不适合作为冗余字段。

另外,在这些范式之外,还有其他一些设计原则也很重要,我挑几点重要但却容易被忽略的点说说:

  1. 优先用逻辑主键,而非业务主键。逻辑主键最简单的就是自增ID,业务主键比如用户的邮箱,业务主键虽然变动的可能性低,但并非真的一成不变的,而且业务主键的查询效率一般也比较低。
  2. 尽量不要建立外键。外键会产生强耦合,不利于以后表的扩展和重构,尽量保证每个表的独立性,表之间的关系最好通过 ID 进行关联。
  3. 不要有 NULL 值。如果是数字,可以设置默认值为 0,如果是字符,那就设置默认为空字符串,空值不占用空间,NULL 还需要占用空间。有 NULL 值的话,索引效率也会下降很多。

服务端设计思考

服务端是个单体应用,对其的设计,主要是对内部模块的划分,以及代码结构的组织。

关于编程语言方面的选型

主要也是看团队所熟悉的技术栈了,如果是从 0 到 1 搭建团队来做,我推荐尽量用 Golang,因为交易系统的特性就是高并发,而 Golang 的语言特性就非常适合开发高并发的应用。

微服务设计的思考

「分布式」和「微服务」是两个不同的概念。微服务是分布式的,但分布式并不一定用微服务。其实,在实际项目中,从单体应用到微服务应用也不是一蹴而就的,而是一个逐渐演变的过程。

现在,好多小团队小项目,一上来就微服务,很多只是为了微服务而微服务,这绝对不是合适的做法。从本质上来说,架构的目的是为了「降本增效」——这四字真言是我从玄姐(真名孙玄)那学来的。项目一开始就采用微服务,一般都达不到降本增效的目的,因为微服务架构应用相比单体应用,其实现成本、维护成本都比单体应用高得多,除非一开始就是构建一个大型应用。

当业务规模和开发人员规模都已经不小的时候,比较适合用微服务,这时候用微服务主要解决两个问题:快速迭代高并发。当业务和人员规模比较小的时候,用一个或几个单体应用完成整个系统,一般迭代速度会更快。但到了某个临界点,就会开始出现一个或多个痛点,这之后,反而会拖慢迭代速度。

而遇到高并发时,其实,单体应用只要是无状态化的,通过部署多个应用实例,也可以承载一定的并发量。但如果单体应用变得庞大了,承载了比较多业务功能的时候,再对整个单体应用横向扩容,就会严重浪费资源。因为,并非所有业务都是需要扩容的,比如,下单容易产生高并发,需要扩容,但注册并不需要扩容。全部业务都绑定到同个单体中一起扩容,那消耗的资源就会比较庞大。另外,当某一块业务出现高并发,服务器承载不了的时候,影响的是该单体应用的所有业务。因此,拆分微服务就可以解决这些因为高并发而导致的问题。

1.1 业务拆分

在需求不断迭代增加的过程中,项目也会变的越来越臃肿,加班加点把这些业务板块的需求都上线之后,做个复盘总结,就会发现存在几个比较严重的问题:

  • 客户端后台服务变得好臃肿,里面的好多业务逻辑也变得好复杂,提交代码出现冲突的情况越来越频繁,严重影响了迭代速度。
  • 开放 API 和内部 API 的强耦合,导致开放 API 访问量高的时候,就影响到了内部 API 的访问,从而使得客户端用户时不时就反映说应用慢和卡,甚至超时。
  • 当某个业务板块的交易请求并发量很大的时候,服务器承载不了,导致所有业务都不可用。

服务拆分的时机,是由痛点驱动的。以上这些问题,就是已经出现的痛点,那要解决这些痛点,方法就是一个字:「」。那接下来的问题就是:如何拆分p>

微服务拆分,本质就是对业务复杂度进行分解,将整套系统分解为多个独立的微服务,就可以让不同小团队负责不同的微服务,实现整个微服务从产品设计、开发测试,到部署上线,都能由一个团队独立完成。从而,多个小团队就能并行研发多条业务线,实现整套系统的快速迭代。

因此,进行服务拆分

  • 考虑的第一个拆分维度就是相互独立的业务域。
  • 考虑第二个拆分维度,分析业务流程,如果有异步操作,那就可以拆分。

1.2 数据库拆分

业务服务都拆分之后,大一统的数据库就很容易成为性能瓶颈,且还有单点故障的风险。另外,只有一个数据库,所有服务都依赖它,数据库一旦进行调整,就会牵一发而动全身。所以,我们要将数据库也进行拆分。

微服务架构下,一套完整的微服务组件,其独立性不仅仅只是代码上对业务层需求上的研发和部署上线独立,还包括该业务组件对自身的数据层的独立自治和解耦。因此,理想的设计是每个微服务业务组件都有自己单独的数据库,其他服务不能直接调用你的数据库,只能通过服务调用的方式访问其他服务的数据。所以,数据库如何拆分,基本也是跟随业务组件而定。

但拆分之后,数据库变成了分布式的,不可避免地就会引入一些新问题,主要有三大块:

  1. 分布式事务问题
  2. 数据统计分析问题
  3. 跨库查询问题

分布式事务:单个数据库的时候,数据库事务的 ACID 是很容易达到的。但到了分布式环境下,ACID 就很难满足了,就需要在某些特性之间进行平衡取舍。我们应该知道,分布式环境下,有个 CAP 理论,即一致性、可用性、分区容忍性,三者在分布式系统中无法同时满足,最多只能满足两项。P 是必选项,所以一般就需要在 C(一致性)和 A(可用性)之间进行抉择。如果是选择了 C,则需要保证强一致性,保证强一致性的事务,也称为刚性事务。解决刚性事务的方案主要有 2PC、3PC,能够保证强一致性,但性能很差。大部分场景下的分布式事务其实对强一致性的要求不会太高,所以只要在一定时间内做到最终一致性就可以了。保证最终一致性的事务,称为柔性事务,其设计思想则是基于 BASE 理论。柔性事务的解决方案主要有 TCC补偿型、异步确保型、最大努力型。而具体选择哪种方案来解决分布式事务问题,就要根据具体的业务场景来分析和选型了。关于分布式事务更详细的内容,以后再单独细说。

数据统计分析问题,更多是管理后台的需求,管理后台需要为运营人员提供统计 表、数据分析等功能,这其实可以归到 OLAP 的范畴了,因此推荐的方法就是将各个库的数据整合到 NewSQL 数据库里进行处理。

跨库查询,其实最多的场景就是 A 业务组件需要查询 B 业务组件甚至更多其他业务组件的数据的时候,那要解决此问题,常用的有几种解决思路。

  • 第一种思路就是增加冗余字段,但冗余字段不宜太多,且还需要解决冗余字段数据同步的问题。
  • 第二种方案则是增加聚合服务,将不同服务的数据统一封装到一个新的服务里做聚合,对外提供统一的 API 查询接口。
  • 还有一种方案就是分次查询每个服务,再组装数据,可以直接在客户端做,也可以在服务端做。

1.3 水平分层

微服务化的最后一步拆分则是采用水平方向的分层架构,可用最简单的三层架构,将所有微服务划分为 关层、业务逻辑层、数据访问层

增加 关层是很好理解的,这是所有微服务系统的标配。 关层是整个系统的后端总入口,对内提供给官方的客户端和管理端访问,对外通过开放 API 的形式提供给第三方应用访问,负责对请求鉴权、限流、路由转发等,不会涉及具体的业务逻辑。

增加 关层是毋庸置疑的,这不用考虑,需要考虑的是配置多少个 关才合适统一的 关无法解决我们前面提到的开放 API 和内部 API 强耦合的问题,所以是肯定需要多 关的。开放 API 和内部 API 是应该要分开的,两者的鉴权方式不同,限流的策略也不同,更重要的是考虑隔离性,互不影响。管理端和客户端的 API 也最好分开,两者用户和权限是不同的,管理端的管理员用户有着访问和操作更多数据的权限,如果不小心泄露给到了客户端,那就是严重的安全事故了。所以, 关层至少可以分为三个 关:Open API Gateway(开放API 关)、Client API Gateway(客户端API 关)、Admin API Gateway(管理端API 关)。与各端的访问关系如下图:

1.4 注册中心

注册中心主要解决以下几个问题:

  • 服务注册后,如何被及时发现
  • 服务宕机后,如何及时下线
  • 服务发现时,如何进行路由
  • 服务异常时,如何进行降级
  • 服务如何有效的水平扩展

简单地说,注册中心主要实现服务的注册与发现。现在,注册中心的选型有好多种,包括 Zookeeper、Eureka、Consul、Etcd、CoreDNS、Nacos 等,还可以自研。这么多选择,应该选哪个比较好呢,可以先从 CAP 理论去考虑,本质上,注册中心应该是 CP 模型还是 AP 模型的p>

对于服务发现场景来说,即使注册中心的不同节点保存的服务提供者信息不尽相同,也并不会造成灾难性的后果。因为对于服务消费方来说,能消费才是最重要的,拿到不正确的服务提供方节点信息好过因为无法获取服务节点信息而不去消费。

对于服务发现场景来说,针对同一个服务,即使注册中心的不同节点保存的服务提供者信息不尽相同,也并不会造成灾难性的后果。但是对于服务消费者来说,如果因为注册中心的异常导致消费不能正常进行,对于系统来说则是灾难性的。因此,对于服务发现来讲,注册中心应该是 AP 模型。

再从服务注册的角度分析,如果注册中心发生了 络分区,CP 场景下新的节点无法注册,新部署的服务节点就不能提供服务,站在业务角度这是我们不想看到的,因为我们希望将新的服务节点通知到尽量多的服务消费方,不能因为注册中心要保证数据的一致性而让所有新节点都不生效。所以,服务注册场景,也是 AP 的效果好于 CP 的。

综上所述,注册中心应该优先选择 AP 模型,保证高可用,而非强一致性。那以上所罗列出来的注册中心,可选的就只剩下 Eureka、Nacos,或者自研了。在实际项目中,自研的相对比较少,现在越来越多项目选择了使用 Nacos,因为其功能特性最强大,而且 Nacos 不只是注册中心,还是配置中心。所以,如果不是自研的话,其实可以直接选择 Nacos。

总结

微服务化的落地,远没有很多 上教程说的那么简单,只有自己去经历过,才知道最佳实践的落地方法。另外,微服务化之后,后续还有很多更复杂的问题需要一一去解决,包括服务治理,比如服务降级、熔断、负载均衡等,以及服务 格化,甚至无服务化,都是需要一步步去实施的。

设计认识升级

1.1 对于TLS以及用户密码加密问题

从用户在客户端输入密码,到 络传输,再到服务端数据存储,在整个流程中,为了保证密码的安全性,最佳实践的方案应该是怎样的是我总结出来的几个要点:

  1. TLS 是基础;
  2. 密码再进行单独加密,加密算法要用非对称加密,比如 RSA、ECC
  3. 如果用户登录时密码错误,那错误提示语不要直接提示“密码错误”,只需要给出一个大概的提示,比如“用户名或密码错误”;
  4. 密码错误次数连续超过 N 次,比如 6 次,则将用户锁定一段时间;
  5. 数据库用 慢哈希 + Salt 的方案进行存储,不同用户用不同 Salt 值,慢哈希算法主要有:Argon2、Scrypt、Bcrypt、PBKDF2
  6. 增加多重校验,比如登录设备检测、指纹识别、人脸识别、手机验证码等。

不少人觉得已经有 TLS 就可以不对密码再进行单独加密了,这其实是不对的。TLS 能保证的只是传输过程中第三方抓包看到的是密文,但防不了在客户端和服务端截取数据的黑客,要在服务端截取数据比较难,但在客户端截取还是比较容易的。最简单的,你在浏览器访问知乎、京东等知名 站并用抓包工具抓取请求,就会发现,虽然是 HTTPS 请求,但看到的数据并非密文,而是明文的。对 HTTPS 的攻击手段也不少,比如降级攻击、中间人攻击等。所以,只用 HTTPS 做防护是不够的。

  • 对密码加密为什么推荐用非对称加密,而不用单向哈希或对称加密呢strong>

如果用单向哈希,比如 MD5/SHA,那对服务端来说,实际密码是哈希后的值,而不是用户的原密码,以后要升级加密算法就很麻烦;那对黑客来说,也没必要破解出用户的原密码,就直接用哈希后的密码向服务端请求即可通过校验。如果用对称加密,比如 AES/DES,那客户端需要安全保存加密密钥,这是非常难的。而用非对称加密,公钥保存在客户端就算泄露了也没有关系。

1.2 数据库存储方面

数据库存储方面,以前思考的方向是如何防止数据泄露,但现在更多考虑的则是泄露后如何防止数据被还原。意思就是说,我们要做到,就算被盗取了所有数据和代码,依然难以破解出原密码。要实现此目标,主要思路就是增加破解的成本,要让破解的成本远大于收益,这样就没有破解的意义了。而慢哈希 + Slat 的方案就能达到此目标,由于每个用户的 Salt 值不同,就无法用彩虹表进行批量破解;而加上慢哈希,要暴力破解的时间成本也呈指数级增加。

增加多重校验则让安全性更上 N 层楼了,现在微信、支付宝、以及很多金融类应用,都普遍只用 6 位数字的支付密码,之所以安全,也是因为有了多重校验机制。假设每层校验单独被破解的概率为 30%,那加上三层校验之后,被破解的概率就变为:30% * 30% * 30% = 2.7%,安全性大大提升。

  • 彩虹表:彩虹表是一个用于加密散列函数逆运算的预先计算好的表, 为破解密码的散列值(或称哈希值、微缩图、摘要、指纹、哈希密文)而准备。一般主流的彩虹表都在100G以上。 这样的表常常用于恢复由有限集字符组成的固定长度的纯文本密码。这是空间/时间替换的典型实践, 比每一次尝试都计算哈希的暴力破解处理时间少而储存空间多,但却比简单的对每条输入散列翻查表的破解方式储存空间少而处理时间多。使用加salt的KDF函数可以使这种攻击难以实现。彩虹表是马丁·赫尔曼早期提出的简单算法的应用.[百度百科]
  • 慢哈希:所谓慢哈希,其实就是指执行这个哈希函数非常慢,这样暴力破解需要枚举遍历所有可能结果时,就需要花上非常非常长的时间。最好对不同用户的密码随机生成不同的salt,salt库和密码库分离开,同时慢哈希算法可以有效抵御彩虹表攻击,即使数据泄露,最关键的“用户密码”仍然可以得到有效的保护,黑客无法大批量破解用户密码,从而切断撞库扫 的根源。当然,对于已经泄露的密码,还是需要用户尽快修改密码,不要再使用已泄露的密码
  • 加盐:彩虹表的反推,使md5加密也不安全了,所以一些的程序员想出了个办法,即使用户的密码很短,只要我在他的短密码后面加上一段很长的字符,再计算 md5 ,那反推出原始密码就变得非常困难了。加上的这段长字符,我们称为盐(Salt),通过这种方式加密的结果,我们称为  。比如:md5(md5(password)+salt),但是这种也不是特别安全,假如攻击者拿到了salt,然后穷举出6位密码的所有md5(md5(password)+salt)值,那么依旧会被破解,所以更安全的一种方法就是加盐慢哈希加密。

1.3 数据库读性能瓶颈的问题

大部分人最先想到的解决方案就是读写分离。读写分离其实就是将数据库分为了主库从库,读请求到从库读,主库处理写请求,写完数据之后再复制到从库。这样,就将大量读操作的压力转移到从库了,如果单个从库无法支撑大量读请求,还可以部署多个从库,实现负载均衡。一般用 MyCat 来实现读写分离。不过,使用读写分离的话,还会存在主从数据一致性的问题。主库和从库需要达成数据一致性,从库才能读取到正确数据,因此,主从之间就存在数据同步(复制)的机制。虽然,主从复制有三种机制:异步复制、全同步复制、半同步复制。但不管用哪种复制方案,由于数据库已经变成了集群化,那高性能和一致性之间就难以兼得。

  • 异步复制能保持高性能,但无法保证数据的一致性。
  • 全同步复制保证了一致性,但严重牺牲了性能。
  • 半同步复制是个折中方案,算是取两者的平衡。

其实,要解决我们的问题,用读写分离并不是唯一方案,更不是最优方案。除了读写分离,还可以使用 Redis 缓存,还可以使用 MongoDB。应该优先选择用 Redis 缓存,因为其读写性能是最高的。那么,选择了 Redis,接下来就需要思考用什么样的数据结构去存储对应场景的数据更合适。

1.4 分布式事务

(1)从ACID 说起

ACID 是数据库事务正常执行的四个特性,分别指:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)

  • 原子性:要求一个事务的一系列操作要么全部完成,要么全部不完成,不能停滞在中间某个环节。如果中间发生错误,应该回滚到事务开始前的状态。
  • 持久性:则要求事务结束后,其结果应该是持久化的。在数据库层面,普遍都是用 WAL(Write-Ahead Logging) 技术来保证原子性和持久性的。
  • 隔离性:是为了应对并发事务的,要求并发执行的各个事务之间是相互隔离的,防止多个事务并发执行时由于交叉执行导致数据的不一致。如果不考虑隔离,则可能会出现脏读、不可重复读、幻读等问题。本质上,隔离的实现其实就是并发控制。SQL 标准中,定义了 4 种隔离级别,由低到高分别为:未提交读(Read Uncommitted)、已提交读(Read Committed)、可重复读(Repeatable Read)、串行化(Serializable)。隔离级别越低的事务,并发性更好,但一致性更低。而标准定义的这 4 种隔离级别,其实只适用于基于的事务并发控制。后来,出现了基于 MVCC(多版本并发控制) 机制的隔离方案,该机制相对于基于锁的并发控制主要特点是读不上锁,这种特性对于读多写少的场景,大大提高了系统的并发性能,因此大部分数据库都实现了 MVCC。
  • 一致性:很容易和 CAP 中的 C 混淆,但其实两者是不同概念。CAP 中的一致性,具体到数据库上,指的是在分布式数据库中,每一个节点对于同一个数据必须有相同的拷贝。而事务的一致性,确保事务只能将数据库从一种有效状态转移到另一种有效状态,并保持数据库不变性,不存在可感知的中间状态。所谓有效状态就是满足预定的约束,包括数据库层面的各种约束,也包括业务逻辑上的约束。

解释事务一致性最常用的例子就是转账,假设 A 向 B 转账 100 元。如果 A 的账户余额只剩下 90 元,而数据库对账户余额的约束条件是不能小于 0,那如果还能转账成功的话,A 的账户余额将变成负数,不符合约束条件,也就不满足一致性。如果 A 的账户余额充足,那就需要分两个步骤,先扣减 A 的账户余额 100 元,再给 B 的账户余额增加 100 元,如果只完成了第一步,事务就结束了,那业务逻辑上就是不正确的,即整个事务也不满足一致性。只有 A 的账户余额扣减了 100 元,同时 B 的账户余额增加了 100 元,两步都一起成功,且不受其他并发事务的干扰,这时整个事务才保证了一致性。

从本质上来说,原子性、隔离性、持久性,最终目的都是为了保证一致性。即一致性是最终目标,原子性、隔离性、持久性可以说都是为了实现这一目标的手段。

所以,不管是本地事务,还是分布式事务,最终的目标都是为了保证一致性。只是针对不同场景,有着不同的实现方案,且对一致性的强弱程度有所取舍。

(2)XA 规范

分布式事务的解决方案有很多种,XA 规范是其中一种有代表性的标准方案,是由 X/Open 组织在 1991 年提出来的,该规范的文档为:《Distributed Transaction Processing: The XA Specification》。XA 规范里描述了一个 DTP 模型,这是一个实现分布式事务处理系统的概念模型。其实不只是 X/Open,OSI 其实也有正式文档对 DTP 模型进行了描述。XA 规范里描述了该模型包含三类角色:

  • AP:Application Pragram,应用程序,定义了事务的边界,以及指定了组成一个事务的行为,可以理解为就是事务发起的某个微服务。
  • RM:Resource Managers,资源管理器,有多个,可以理解为就是分布式数据库中的每一个数据库实例。
  • TM:Transaction Manager,事务管理器,负责协调和管理事务,是一个控制全局事务的协调者。

之所以引入 TM,是因为整个全局事务被分散到多个节点之后,每个节点虽然可以知道自己操作是否成功,但是却无法得知其他节点上操作是否成功,靠分散的节点自身并无法保证全局事务的一致性,因此需要引入一个协调者来管理全局,进而才能保证全局事务的 ACID。

这三者的关系图如下:

XA 规范里还定义了一系列接口,称为 XA 接口,用于 TM 和 RM 之间通讯的接口,主要包含了以下这些接口:

TM 与 RM 之间实现事务的完成和回滚,是使用了 2PC(Two-Phase Commit) 协议——即两阶段提交协议来实现的。2PC 协议的提出时间相比 XA 规范早得多,最早是分布式事务的专家 Jim Gray 在 1977 年的一篇文章 《Notes on Database Operating Systems》 中提及。

(3)2PC 协议

2PC = Two-Phase Commit,两阶段提交,将事务分割成先后两个阶段:Prepare 阶段和 Commit 阶段。

在开始两阶段提交之前,涉及的 RM 是需要先注册到 TM 的。然后,AP 向 TM 发起一个全局事务,之后,就开始进入该事务的两阶段提交了。

Prepare 阶段,由 TM 向涉及的每个 RM 都发送 prepare 请求,并等待 RMs 的响应。RM 接收到请求之后,执行本地事务但不会提交,并记录下事务日志,即 undoredo 日志。RM 的本地事务如果执行成功,则返回给 TM ok 的响应,如果本地事务执行失败,则响应 error。在这一阶段,RM 执行本地事务成功的话,因为没有提交,就会一直锁定事务资源,并等待 TM 的下一步指令。

Prepare 阶段结束后,会存在三种可能性:

  • 所有的 RM 都响应 ok
  • 一个或多个 RM 响应 error
  • TM 等待 RM 的响应超时

Commit 阶段,根据以上三种不同结果,会执行不一样的操作。

  1. 如果是所有 RM 都响应 ok,那 TM 就向所有 RM 发送 commit 请求。
  2. 如果出现其他两种情况,则由 TM 向所有 RM 发送 rollback 请求。RM 收到 commit 请求的话,就会将上一阶段未提交的本地事务进行提交操作,如果收到 rollback 请求,那就回滚本地事务。

整个流程大致如下图:

流程上虽然简单,但分布式系统,随时可能发生 络超时、 络重发、服务器宕机等问题,因此也会给分布式事务带来一些问题。主要有幂等处理、空回滚、资源悬挂这几个问题。

当 TM 向 RM 发送 commit/rollback 请求时,如果出现 络抖动等原因,导致请求超时或中断,那 TM 就需要向 RM 重复发送 commit/rollback 请求。对 RM 来说,第一次 commit/rollback 请求可能已经接收到了,且已经处理过了,但因为 络原因导致 TM 没收到响应。那 RM 再次收到同样的 commit/rollback 请求,肯定不能再处理一次本地事务。正确的做法就是 RM 的 commit/rollback 接口需要保证幂等性

如果 RM 没收到 prepare 请求,但收到了 rollback 请求,那这个 rollback 请求其实是无效的,即本次 rollback 就属于空回滚。要解决空回滚的问题,那 rollback 时需要识别到前一阶段的 prepare 是否已经执行。

如果 prepare 请求因为 络拥堵而超时,之后 TM 发起了 rollback,而最终 RM 又收到了超时的 prepare 请求,但 rollback 比 prepare 先到达 RM。这种情况下,收到 prepare 的时候,整个全局事务其实已经结束了,如果再执行 prepare 请求,就会锁定相关资源,但事务已经结束,锁定的资源将无法释放。至此,就形成了资源悬挂

解决这三个问题的方案,普遍都可以用事务状态控制表来解决,该表主要包含了全局事务ID、分支事务ID、分支事务状态。

XA/2PC 小结

XA/2PC 用在分布式事务,一般情况下能够保证事务的 ACID 特性,能利用数据库自身的实现进行本地事务的提交和回滚,对业务没有侵入。但 2PC 的缺点主要有以下几个:

  1. 同步阻塞:在执行过程中,所有 RM 都是事务阻塞型的,如果 RM 占有了公共资源,那其他第三方要访问公共资源时就会处于阻塞状态。
  2. TM单点故障:一旦 TM 发生故障,RMs 会由于等待 TM 的消息,而一直锁定事务资源,导致整个系统被阻塞。
  3. 数据不一致:在第二阶段中,当 TM 向 RMs 发送 commit 请求之后,发生了局部 络异常或者在发送 commit 请求过程中 TM 发生了故障,导致只有部分 RMs 接到了 commit 请求。这部分 RMs 接到 commit 请求之后就会执行 commit 操作,但是其他未接到 commit 请求的 RMs 则无法执行事务提交。于是整个分布式系统便出现了数据不一致的现象。
  4. 事务状态不确定:TM 发出 commit 消息之后宕机,而接收到这条消息的 RM 同时也宕机了,那么即使通过选举协议产生了新的 TM,这条事务的状态也是不确定的,集群中无法判断出事务是否已经被提交。

2PC 最大的问题其实是性能差,处理短事务可能还好,要是处理长事务,那资源锁定时间更长,性能更差,根本无法忍受。

为了改进 2PC,后来又提出了 3PC。3PC 给 RMs 也增加了超时机制,而且把整个事务拆成了三个阶段。不过,3PC 也只是解决了 2PC 的部分问题,并没有解决性能差的问题,而且因为多增加了一个阶段,导致性能更差了。因此,3PC 几乎没人使用,我也没找到落地实现,所以我也不打算深入去讲解 3PC。2PC 虽然有缺陷,反而还有落地实现,开源框架就有 Atomikos、Bitronix 以及 Seata 的 XA 模式,另外,大部分主流数据库厂商也落地实现了 XA/2PC。因此,对强一致性有要求的场景,2PC 依然还是最佳选择。

说到开源框架,我要补充一下,目前成熟的分布式事务框架,包括下面提到的,基本都是基于 Java 的,其他语言的落地实现都还不成熟。

(4)柔性事务

符合 ACID 特性的事务,也可以称为刚性事务,主要保证强一致性。XA/2PC 就是解决刚性分布式事务的主要方案,但因为性能太差,并不适合高性能、高并发的互联 场景。为了解决性能问题,就有人基于 BASE 理论提出了柔性事务的概念。BASE 理论其实就是 Basically Available(基本可用)、Soft state(软状态)、Eventual consistency(最终一致性) 三个短语的缩写,其核心思想就是:

前面我们说过,事务的(强)一致性,确保事务只能将数据库从一种有效状态转移到另一种有效状态,不存在可感知的中间状态。柔性事务就允许数据存在中间状态(即软状态),只要经过一段时间后,能达到最终一致性即可。最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。跨行转账就是最好的一个示例,转账时,其实有个资金冻结的中间状态,且要经过一段时间才知道转账结果,即达到最终一致性的结果。

  • 刚性事务在隔离性方面,主要是通过资源锁定的方式实现资源隔离的,在数据库层面自身就提供了这种隔离实现,不需要业务实现。而柔性事务则一般不用锁,而是通过资源预留(比如冻结金额)的方式实现隔离,且这种资源预留的隔离方式,是需要业务自己去实现并保证隔离性的。
  • 柔性事务的解决方案主要分为补偿型通知型两大类,补偿型的方案主要有 TCCSaga 两种模式,通知型的方案则又分为事务消息型最大努力通知型。补偿型事务一般是同步的,通知型事务则是异步的,所以也有同步事务异步事务的划分。

    

(5)TCC

TCC = Try-Confirm-Cancel,就是三个单词的缩写。TCC 最早的出现其实可以追溯到 2007 年的一篇论文:《Life beyond Distributed Transactions: an Apostate’s Opinion》,在该论文中,其实原本的三个单词是 Tentative-Confirmation-Cancellation,正式以 Try-Confirm-Cancel 作为名称的是 Atomikos 公司,且还注册了 TCC 商标。

TCC 其实也是基于 2PC 的设计思路演变过来的,也同样分两个阶段进行事务提交,第一阶段提交 Try 接口,第二阶段提交 Confirm 或 Cancel 接口。TCC 的 Try-Confirm-Cancel 虽然与 2PC 的 Prepare-Commit-Rollback 很相似,但实现却大不同。

  • 2PC 其实是数据库层面或者说是资源层面的分布式事务方案,Prepare-Commit-Rollback,这几个操作其实都在数据库内部完成的,开发者层面是感知不到的。
  • TCC 则是业务层面的分布式事务方案,Try-Confirm-Cancel 都是在业务层面实现的操作,开发者是能感知到的,是需要开发者自己去实现这几个操作的。

Try 阶段:主要会完成所有业务检查,并对需要用到的业务资源转为中间状态,通过该方式实现资源预留。比如,转账时将金额冻结。

  • 如果 Try 阶段,所有分支业务都回复 OK,第二阶段就对所有分支业务提交 Confirm,确认执行业务,这时候就无需再做业务检查了,而是直接将中间状态的资源转为最终状态。
  • 如果 Try 阶段,并非所有分支业务都回复 OK,这时就要走补偿机制了,对那些 Try 操作 OK 的分支业务,执行 Cancel 补偿操作,回滚 Try 操作,将中间状态的资源恢复到事务前的状态。

TCC 的具体流程如下图所示:

注意:

  • 第一阶段的 Try 接口是由业务应用调用的(实线箭头)
  • 第二阶段的 Confirm 或 Cancel 接口则是事务协调器 TM 调用的(虚线箭头)。

这就是 TCC 模型的二阶段异步化功能,分支业务服务的第一阶段执行成功,业务应用就可以提交完成,然后再由事务协调器异步地执行各分支业务服务的第二阶段。

  • TCC 中会添加事务日志,如果 Confirm 或者 Cancel 阶段出错,则会进行重试,所以这两个阶段都需要支持幂等。如果重试失败,则需要人工介入进行恢复和处理了。TCC 除了需要支持幂等处理,前面提到的空回滚、资源悬挂的问题也同样需要解决。
  • TCC 相对 2PC,因为对资源不加锁,不会影响并发事务对资源的访问,所以性能得到大幅提高,就适合处理高并发的场景,也适合处理长事务。

TCC 的缺点就是对业务侵入性太强,需要大量开发工作进行业务改造,给业务升级和运维都带来困难。TCC 落地实现的开源框架主要有 ByteTCC、TCC-transaction、Himly、Seata TCC 模式等。

(6)Saga

Saga 的起源比 TCC 早得多,起源于 1987 年的一篇论文《Sagas》,讲述的是如何处理 long lived transaction(长活事务)。Saga 的核心思想就是将一个长事务分解为多个短事务(也叫子事务),每个子事务都是能保证自身一致性的本地事务,且每个子事务都有相应的执行模块和补偿模块。当其中任意一个子事务出错了,就可以通过调用相关的补偿方法恢复到事务的初始状态,从而达到事务的最终一致性。

总的来说,Saga 的组成包含两部分:

  • 每个 saga 事务由一系列子事务 Ti 所组成
  • 每个 Ti 都有对应的补偿动作 Ci,用于撤销 Ti 造成的结果

和 TCC 相比,Saga 事务没有分两阶段提交,没有“预留”的动作,Ti 是直接提交到库的。因此,有人就会提问了:那 Saga 怎么保证隔离性的呢,Saga 本身并不保证隔离性,需要业务自己控制并发,即在业务层自己实现对资源的加锁或预留

最佳情况就是整个子事务序列 T1, T2, …, Tn 全部都执行成功,整个 Saga 事务也就执行成功了。

如果执行到某一子事务失败了,那有两种恢复方式:向前恢复和向后恢复。

  • 向前恢复:重试失败的事务,假设每个子事务最终都会成功
  • 向后恢复:补偿所有已完成的事务,本质就是所有已完成的本地事务进行回滚操作

显然,向前恢复就没必要提供补偿方法。如果你的业务中,子事务最终总会成功,或补偿方法难以定义或不可能,向前恢复更符合你的需求。

向后恢复的话,如果出现子事务失败,会立即将失败信息响应给 AP,之后的补偿操作则是异步执行的。

理论上,补偿方法是必须要成功的,如果执行补偿操作时,因为服务器宕机或 络抖动等原因导致补偿失败,那就需要对补偿方法也进行重试,如果重试依然失败,那就需要人工介入进行处理了。

Saga 的实现方式,主要分集中式非集中式两种。

  • 集中式的实现需要依赖中心化的协调器(TM)负责服务调用和事务协调,主要是基于 AOP Proxy 的设计实现,华为的 ServiceComb Saga 就用这种实现方式。集中式的实现方式比较直观并且容易控制,开发简单、学习成本低,缺点就是业务耦合程度会比较高。
  • 非集中式的实现,也称为分布式的实现,不依赖于中心化的 TM,而是通过事件驱动的机制进行事务协调,Seata Saga 就采用了这种机制,实现了一个状态机。非集中式的实现的优点:采用事件源的方式降低系统复杂程度,提升系统扩展性, 处理模块通过订阅事件的方式降低系统的耦合程度。缺点则是:Saga 系统会涉及大量的业务事件,这样会对编码和调试带来一些问题;还有就是相关的业务逻辑处理是基于事件,相关事件处理模块可能会有循环依赖的问题。

Saga 因为没有两阶段提交,所以,Saga 处理事务请求所花费的时间可能只是 TCC 的一半, 因为 TCC 需要与每个服务至少进行两次通信,而 Saga 只需要通信一次。因此,理论上,Saga 的性能比 TCC 至少可以高一倍。且因为 Saga 对业务的侵入性较小,所以 Saga 是目前行业内落地较多的成功方案。

(7)事务型消息(MQ事务消息|本地消息表)

事务消息型,也称异步确保型,核心思路就是:消息队列(MQ)来保证最终一致性。相比同步的补偿型方案,引入 MQ 的异步方案,主要有以下优点:

  • 可以降低不同分支事务的微服务之间的耦合度
  • 可以提高各服务的吞吐量
  • 可以增强整体服务的可用性

那么,引入 MQ 之后,最核心的问题在于如何解决与两者的一致性问题。即 MQ 消息的上游服务处理完本地事务之后,如何才能保证消息可靠地传递给到下游服务。而目前业界解决该问题的方案有两种:

  • 基于 MQ 自身的事务消息
  • 基于 DB 的本地消息表

MQ 事务消息

基于 MQ 自身的事务消息方案,据了解,目前只有 RocketMQ 提供了支持,其他主流的 MQ 都还不支持,所以我们对该方案的解说都是基于 RocketMQ 的。该方案的设计思路是基于 2PC 的,事务消息交互流程如下图所示:

其中,涉及几个概念要说明一下:

  • 事务消息:消息队列 MQ 提供类似 X/Open XA 的分布式事务功能,通过 MQ 事务消息能达到分布式事务的最终一致。
  • 半事务消息:暂不能投递的消息,发送方已经成功地将消息发送到了 MQ 服务端,但是服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,处于该种状态下的消息即半事务消息。
  • 消息回查:由于 络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,MQ 服务端通过扫描发现某条消息长期处于“半事务消息”时,需要主动向消息生产者询问该消息的最终状态(Commit或是Rollback),该询问过程即消息回查。

事务消息发送步骤如下:

  1. 发送方将半事务消息发送至 MQ 服务端。
  2. MQ 服务端将消息持久化成功之后,向发送方返回 Ack 确认消息已经发送成功,此时消息为半事务消息。
  3. 发送方开始执行本地事务逻辑。
  4. 发送方根据本地事务执行结果向服务端提交二次确认(Commit 或是 Rollback),服务端收到 Commit 状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到 Rollback 状态则删除半事务消息,订阅方将不会接受该消息。

事务消息回查步骤如下:

  1. 在断 或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达服务端,经过固定时间后服务端将对该消息发起消息回查。
  2. 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
  3. 发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行操作。

有一点需注意,如果发送方没有及时收到 MQ 服务端的 Ack 结果,那就可能造成 MQ 消息的重复投递,因此,订阅方必须对消息的消费做幂等处理,不能造成同一条消息重复消费的情况。

  • MQ 事务消息方案的最大缺点:就是对业务具有侵入性,业务发送方需要提供回查接口。

本地消息表

本地消息表方案最初是由 ebay 提出的,后来通过支付宝等公司的布道,在国内被广泛使用。对于不支持事务消息的 MQ 则可以采用此方案,其核心的设计思路就是将事务消息存储到本地数据库中,并且消息数据的记录与业务数据的记录必须在同一个事务内完成。将消息数据保存到 DB 之后,就可以通过一个定时任务到 DB 中去轮询查出状态为的消息,然后将消息投递给 MQ,成功收到 MQ 的 ACK 确认之后,再将 DB 中消息的状态更新或者删除消息。交互流程如下图所示:

处理步骤如下:

  1. 消息生产者在本地事务中处理业务更新操作,并写一条事务消息到本地消息表,该消息的状态为,业务操作和写消息表都在同一个本地事务中完成。
  2. 定时任务不断轮询从本地消息表中查询出状态为状态的消息,并将查出的所有消息投递到 MQ Server。
  3. MQ Server 接收到消息之后,就会将消息进行持久化,然后返回 ACK 给到消息生产者。
  4. 消息生产者收到了 MQ Server 的 ACK 之后,再从本地消息表中查询出对应的消息记录,将消息的状态更新为,或者直接删除消息记录。
  5. MQ Server 返回 ACK 给到消息生产者之后,接着就会将消息发送给消息消费者。
  6. 消息消费者接收到消息之后,执行本地事务,最后返回 ACK 给到 MQ Server。

因为 MQ 宕机或 络中断等原因,生产者有可能会向 MQ 发送重复消息,因此,消费者接收消息后的处理需要支持幂等。

该方案,相比 MQ 事务消息方案,其优点就是弱化了对 MQ 的依赖,因为消息数据的可靠性依赖于本地消息表,而不依赖于 MQ。还有一个优点就是容易实现。缺点则是本地消息表与业务耦合在一起,难以做成通用性,且消息数据与业务数据同个数据库,占用了业务系统资源。本地消息表是基于数据库来做的,而数据库是要读写磁盘 I/O 的,因此在高并发下是有性能瓶颈的。

(8)最大努力通知

最大努力通知型也是基于事务消息型扩展而来的,其应用场景主要用于通知外部的第三方系统。即是说,最大努力通知型方案,主要解决的其实是跨平台、跨企业的系统间的业务交互问题。而事务消息型方案则适用于同个 络体系的内部服务间的分布式事务。

最大努力通知型一般会引入一个通知服务,由通知服务向第三方系统发送通知消息。其简化的流程如下图:

  • 业务主动方(消息消费者)在完成业务处理后,向业务被动方(第三方系统)发送通知消息,允许存在消息丢失。
  • 业务主动方提供递增多挡位时间间隔(5min、10min、30min、1h、24h),用于失败重试调用业务被动方的接口;在通知N次之后就不再通知, 警+记日志+人工介入。
  • 业务被动方提供幂等的服务接口,防止通知重复消费。
  • 业务主动方需要有定期校验机制,对业务数据进行兜底;防止业务被动方无法履行责任时,需要主动方自己进行业务回滚,确保数据最终一致性。

疑问:

  1. 在很多其他资料都会说“业务被动方根据定时策略,向业务活动的主动方进行轮询,进而恢复丢失的业务消息”;但在真实场景中被动方很多时候可能是业务强势方,不会反向调用 业务主动方的接口;所以我们需要一定的熔断探活机制来保证我们的通知有效性。
  2. 还有很多资料也说“被动方的处理结果不影响主动方的处理结果”,在我的认知中,这句话其实是有缺陷的:在大多数下确实业务被动方的处理结果不影响业务主动方,但在业务被动方确定无法履行业务责任时,业务主动方可能仍需要回滚业务数据。

1.5 分布式事务如何选型

至此,可以解决分布式事务问题的方案我们基本都讲了个遍,那要把分布式事务落地到我们的交易系统中,应该如何选型呢将每种方案先做个对比吧,看下表:

属性

XA/2PC

TCC

Saga

MQ事务消息

本地消息表

最大努力通知型

事务一致性

性能

业务侵入性

复杂性

维护成本

而具体如何选型,其实还是需要根据场景而定。在第一篇文章就说过,我们应该由场景驱动架构,离开场景谈架构就是耍流氓。

  • 如果是要解决和外部第三方系统的业务交互,比如交易系统对接了第三方支付系统,那我们就只能选择最大努力通知型。
  • 如果对强一致性有刚性要求的短事务,对高性能和高并发则没要求的场景,那可以考虑用 XA/2PC,如果是用 Java 的话,那落地实现可以直接用 Seata 框架的 XA 模式。
  • 如果对一致性要求高,实时性要求也高,执行时间确定且较短的场景,就比较适合用 TCC,比如用在互联 金融的交易、支付、账务事务。落地实现如果是 Java 也建议可以直接用 Seata 的 TCC 模式。
  • Saga 则适合于业务场景事务并发操作同一资源较少的情况,因为 Saga 本身不能保证隔离性。而且,Saga 没有预留资源的动作,所以补偿动作最好也是容易处理的场景。
  • MQ 事务消息和本地消息表方案适用于异步事务,对一致性的要求比较低,业务上能容忍较长时间的数据不一致,事务涉及的参与方和参与环节也较少,且业务上还有对账/校验系统兜底。如果系统中用到了 RocketMQ,那就可以考虑用 MQ 事务消息方案,因为 MQ 事务消息方案目前只有 RocketMQ 支持。否则,那就考虑用本地消息表方案。

其实,还有一个选型,就是业务规避,意思就是说可以从业务上稍作调整,从而规避掉分布式事务,这是解决分布式事务问题最优雅的方案,没有之一。业务规避,本质上就是消灭掉问题本身,需要换位思考,跳出惯性思维从不同维度去思考解决问题的方案,有时候可能还会牺牲一些业务特性。不过,现实情况却是,大部分场景下很难做到业务规避,那只能老老实实地解决分布式事务问题。

另外,有些现实场景还具有特殊性,这时候就不能直接套用上面的说法,而要根据具体场景而调整方案。比如,具体到我们的交易系统,我们来看看下单这个业务的分布式事务处理方案。

下单其实存在三个步骤:

  1. 创建订单;
  2. 冻结用户的资产账户余额;
  3. 将订单投递给到撮合引擎进行撮合。

下单事务的发起方是交易服务,第一步也是在交易服务完成的,而第二步应该是在公共服务完成的——因为我们还没有将账户服务抽离出来——第三步则是通过 MQ 将订单投递给到撮合引擎。

从上面提到的场景分类来说,我们的交易场景属于互联 金融的交易事务,那比较适合用 TCC,但最后一步又是异步事务,这又该怎么选呢,前两步用 TCC 保证同步事务的一致性,而第三步用本地消息表来异步确保消息的可靠投递,这样的处理是可以的。但必须前两步的事务执行成功后,才把消息写入消息表。而撮合引擎作为 MQ 的消费者,就需要做幂等处理了。

因此,一个事务有时候并非就只能用一种单一的方案,可以组合,可以演变的。分布式事务问题之所以复杂,最根本的原因也在此,现实场景远比理论复杂多变。

 

声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

上一篇 2021年2月6日
下一篇 2021年2月6日

相关推荐