前言
前段时间开始学习微服务相关的一些基础知识,总结出这篇文章,希望对于同样入门微服务的其他人有所帮助.由于本人接触微服务的时间不长,在本文里有些知识点可能覆盖不到,这将在以后逐渐完善.
思维导图
什么是微服务
微服务应用是与单体应用区分开来的.
当一个单体项目随着业务的发展会越来越膨胀,变得更加难维护,从一开始仅需一两个人到需要两三个团队,多个团队维护同一个项目无疑是一场灾难,沟通成本大大增加,技术协同也会十分困难.
举个例子,我在上一家公司维护过一个后台项目,它是一个公共后台,全司上下各个团队都可能用它来看数据,都来维护它.面对这样一个项目,明明我只需要维护十几个类,但每次我都需要在几百上千个类中找到它们.一些配置项,公用组件我想改却改不了,因为就算我的部分测试通过,也不能保证以后在其他业务会不会产生新的问题,而且出了问题有时很难追踪是谁的责任.
单体项目膨胀到一定程度后,就需要拆分了.微服务本质上就是对单体应用的解耦,拆分,从业务,功能上抽象成若干个模块.代码上解耦/独立只是第一步,根据实际需求,微服务架构也可能需要做到数据库独立,团队独立等.
为什么需要微服务
如上,微服务的出现是为了解决单体架构带来的问题.这里再敞开具体聊聊.
复杂性逐渐变高
比如有的项目有几十万行代码,各个模块之间区别比较模糊,逻辑比较混乱,代码越多复杂性越高,越难解决遇到的问题。
技术债务逐渐上升
公司的人员流动是再正常不过的事情,有的员工在离职之前,疏于代码质量的自我管束,导致留下来很多坑,由于单体项目代码量庞大的惊人,留下的坑很难被发觉,这就给新来的员工带来很大的烦恼,人员流动越大所留下的坑越多,也就是所谓的技术债务越来越多。
部署速度逐渐变慢
这个就很好理解了,单体架构模块非常多,代码量非常庞大,导致部署项目所花费的时间越来越多,曾经有的项目启动就要一二十分钟,这是多么恐怖的事情啊,启动几次项目一天的时间就过去了,留给开发者开发的时间就非常少了。
阻碍技术创新
比如以前的某个项目使用struts2写的,由于各个模块之间有着千丝万缕的联系,代码量大,逻辑不够清楚,如果现在想用spring mvc来重构这个项目将是非常困难的,付出的成本将非常大,所以更多的时候公司不得不硬着头皮继续使用老的struts架构,这就阻碍了技术的创新。
无法按需伸缩
比如说电影模块是CPU密集型的模块,而订单模块是IO密集型的模块,假如我们要提升订单模块的性能,比如加大内存、增加硬盘,但是由于所有的模块都在一个架构下,因此我们在扩展订单模块的性能时不得不考虑其它模块的因素,因为我们不能因为扩展某个模块的性能而损害其它模块的性能,从而无法按需进行伸缩。
微服务特点
微服务主要的特点是组件化,松耦合,自治,去中心化,体现在以下几个方面:
单一职责
服务粒度小,每个服务负责单独的功能,专注于把一件事情做好.
独立部署,运行,升级,拓展和替换
每个服务都可以单独部署及重新部署而不影响整个系统,这使得服务很容易升级和改变,使得快速交付和应对变化成为可能。
支持异构
每个服务的实现细节都与其他服务无关,这使得服务之间能够解耦,团队可以针对每个服务选择最合适的开发语言、工具和方法。
轻量级
微服务通常有轻量级的分布式服务框架承载,采用了P2P通信,无中心节点,性能可以线性增长;第三方软件依赖减少,减少类冲突和冗余依赖,集成和升级更方便。
微服务解决了单体架构的许多痛点,但它也有着运维复杂,开发复杂的缺点,所以这里要重申一个简单的观点:不要盲目给你的项目选择高大上的架构,要懂得取舍,按需选型.
设计原则
如何设计一个好的服务是微服务的核心,这里整理出六大设计原则:
高内聚低耦合
紧密关联的事物应该放在一起,每个服务是针对一个单一职责的业务能力的封装,专注做好一件事情(每次只有一个更改它的理由)。如下图:有四个服务a,b,c,d,但是每个服务职责不单一,a可能在做b的事情,b又在做c的事情,c又同时在做a的事情,通过重新调整,将相关的事物放在一起后,可以减少不必要的服务。
高内聚.jpg 轻量级的通信方式,如HTTP,RPC或者消息队列
避免在服务间共享数据库
以业务为中心
每个服务代表了特定的业务逻辑
有明显的边界上下文
围绕业务组织团队
能快速的响应业务的变化
隔离实现细节,让业务领域可以被重用
高度自治
独立部署运行和扩展
- 每个服务能够独立被部署并运行在一个进程内
- 这种运行和部署方式能够赋予系统灵活的代码组织方式和发布节奏,使得快速交付和应对变化成为可能。
独立开发和演进
- 技术选型灵活,不受遗留系统技术栈的约束。
- 合适的业务问题可以选择合适的技术栈,可以独立的演进
- 服务与服务之间采取与语言无关的API进行集成
独立的团队和自治
- 团队对服务的整个生命周期负责,工作在独立的上下文中, 谁开发,谁维护
弹性设计
- 设计可容错的系统
- 拥抱失败,为已知的错误而设计
- 可避免因某节点宕机而导致服务不可用
- 可应对网络连接问题
- 设计具有自我保护能力的系统
- 服务隔离
- 服务降级
- 限制使用资源
- 防止级联错误
日志与监控
- 聚合你的日志,聚合你的数据,从而当你遇到问题时,可以深入分析原因。
- 监控主要包括服务可用状态、请求流量、调用链、错误计数,结构化的日志、服务依赖关系可视化等内容,以便发现问题及时修复,实时调整系统负载,必要时进行服务降级,过载保护等等,从而让系统和环境提供高效高质量的服务。
自动化测试与部署
微服务框架
框架的话国内用的最多的就是Apache的Spring Cloud和阿里的dubbo,貌似大厂用dubbo的多,小公司用SC的多.这里上张组件对比图:
分库分表
大数据与微服务密切相关,业务的规模上去之后,关系型数据库本身比较容易成为系统的瓶颈点,虽然读写分离能分散数据库的读写压力,但并没有分散存储压力,当数据量达到千万甚至上亿时,单台数据库服务器的存储能力会成为系统的瓶颈,主要体现在以下几个方面:
数据量太大,读写的性能会下降,即使有索引,索引也会变得很大,性能同样会下降。
数据库文件会非常大,数据库备份和恢复需要耗时很长。
数据库文件越大,极端情况下丢失数据的风险越高。
因此,当流量越来越大时,且单机容量达到上限时,此时需要考虑对其进行切分,切分的目的就在于减少单机数据库的负担,将由多台数据库服务器一起来分担,缩短查询时间。
切分策略
数据切分分为两种方式,纵向切分和水平切分
纵向切分 常见有纵向分库,纵向分表两种。
- 纵向分库就是根据业务耦合性,将关联度低的不同表存储在不同的数据库,做法与大系统拆分为多个小系统类似,按业务分类进行独立划分。与“微服务治理”的做法相似,每个微服务使用单独的一个数据库。
- 垂直分表是基于数据库中的列进行,某个表字段较多,可以新建一张扩展表,将不经常用或者字段长度较大的字段拆出到扩展表中。在字段很多的情况下,通过大表拆小表,更便于开发与维护,也能避免跨页问题,MYSQL底层是通过数据页存储的,一条记录占用空间过大会导致跨页,造成额外的开销。另外,数据库以行为单位将数据加载到内存中,这样表中字段长度越短且访问频次较高,内存能加载更多的数据,命中率更高,减少磁盘IO,从而提升数据库的性能。
垂直切分的优点:
- 解决业务系统层面的耦合,业务清晰
与微服务的治理类似,也能对不同业务的数据进行分级管理,维护,监控,扩展等。
高并发场景下,垂直切分一定程度的提升IO,数据库连接数,单机硬件资源的瓶颈。
垂直切分的缺点
部分表无法join,只能通过接口聚合方式解决,提升了开发的复杂度。
分布式事处理复杂
依然存在单表数据量过大的问题。
水平切分
当一个应用难以再细粒度的垂直切分或切分后数据量行数依然巨大,存在单库读写,存储性能瓶颈,这时候需要进行水平切分。
水平切分为库内分表和分库分表,是根据表内数据内在的逻辑关系,将同一个表按不同的条件分散到多个数据库或多表中,每个表中只包含一部分数据,从而使得单个表的数据量变小,达到分布式的效果。
库内分表只解决单一表数据量过大的问题,但没有将表分布到不同机器的库上,因些对于减轻mysql的压力来说帮助不是很大,大家还是竞争同一个物理机的CPU、内存、网络IO,最好通过分库分表来解决。
水平切分优点
- 不存在单库数据量过大、高并发的性能瓶颈,提升系统稳定性和负载能力。
- 应用端改造较小,不需要拆分业务模块。
水平切分缺点
跨分片的事务一致性难以保证
跨库的join关联查询性能较差
数据多次扩展维度和维护量极大。
路由规则
水平切分后同一张表会出现在多个数据库或表中,每个库和表的内容不同,对于水平分表后分库后,如何知道哪条数据在哪个库里或表里,则需要路由算法进行计算,这个算法会引入一定的复杂性。
范围路由
选取有序的数据列,如时间戳作为路由的条件,不同分段分散到不同的数据库表中,以最常见的用户ID为例,路由算法可以按照1000000的范围大小进行分段,1 ~ 9999999放到数据库1的表中,10000000~199999999放到数据库2的表中,以此累推。
范围路由设计的复杂点主要体现在分段大小的选取上,分段太小会导致切分后子表数量过多增加维护复杂度,分段太大可能会导致单表依然存在性能问题,按一般大佬们的经验,分段大小100W至2000W之间,具体需要根据业务选取合适的分段大小。 范围路由的优点:
可以随着数据的增加平滑地扩充新的表或库,原有的数据不需要动。
单表大小可控
使用分片字段进行范围查找时,连续分片可快速定位查询,有效避免分片查询的问题。
范围路由的缺点:
- 数据分布不均,热点数据成为性能瓶颈.连续分片可能存在数据热点,例如按时单字段分片,有些分片存储最近时间内的数据,可能会被频繁读写,而有些历史数据则很少被查询。
Hash算法
选取某个列或几个列的值进行hash运算,然后根据hash的结果分散到不同的数据库表中,以用ID为例,假如我们一开始就规划10个数据库表,路由算法可以简单地用id % 10的值来表示数据所属的数据库编号,ID为985的用户放到编号为5的子表中。ID为10086编号放到编号为6的表中。
Hash路由设计的复杂点主要体现 在初始表数量的选取上,表数量太多维护比较麻烦,表数量太小又可能导致单表性能存在问题。而用Hash路由后,增加表数量是非常麻烦的,涉及到数据迁移问题,所有数据都要重新分布。
Hash路由的优缺点与范围路由相反,Hash路由的优点是表分布比较均匀,缺点是扩容时很麻烦,所有数据均需要重新分布。
路由配置
配置路由就是路由表,用一张独立的表来记录路由信息。同样以用户ID为例,我们新增一张ROUTER表,这个表包含table_Id两列,根据user_id就可以查询对应的修改路由表就可以了。 配置路由设计简单,使用起来非常灵活,尤其是在扩充表的时候,只需要迁移指定的数据,然后修改路由表就可以了。其缺点就是必须多查询一次,会影响整体性能,而且路由表本身如果太大,性能会成为瓶颈点,如果我们再将路由表分库分表,则又面临一个死循环。
分布式事务
在使用了分库分表之后,本地事务随之也变成了分布式事务.
分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
为了更深入了解分布式事务,这里还要介绍几个基础概念:
CAP定理
CAP定理说的是: 一个分布式系统(指互相连接并共享数据的节点的集合)中,当涉及读写操作时,只能保证一致性(Consistence)、可用性(Availability)、分区可容忍性(PartitionTolerance)三者中的两个,另外一个必须被牺牲。
一致性
对某客户端而言,读操作保证能够返回最新的数据.
可用性
非故障的节点在合理的时间内返回合理的结果
分区可容忍性
出现网络分区后(例如某节点宕机),系统仍能正常运作.
虽然 CAP 理论定义是三个要素中只能取两个,但是舍弃容错性选择CA意味着你的系统不是分布式的了.因此分布式系统只能在CP和AP中选择.Spring Cloud(eureka)选择了AP,而dubbo(zookeeper)选择了CP.
- CP - Consistency + Partition Tolerance (一致性 + 分区容忍性)
如上图所示,因为Node1节点和Node2节点连接中断导致分区现象,Node1节点的数据已经更新到y,但是Node1 和 Node2 之间的复制通道中断,数据 y 无法同步到 Node2,Node2 节点上的数据还是旧数据x。
这时客户端C 访问 Node2 时,Node2 需要返回 Error,提示客户端 “系统现在发生了错误”,这种处理方式违 背了可用性(Availability)的要求
AP - Availability + Partition Tolerance (可用性 + 分区容忍性)
同样是Node2 节点上的数据还是旧数据x,这时客户端C 访问 Node2 时,Node2 将当前自己拥有的数据 x 返回给客户端 了,而实际上当前最新的数据已经是 y 了,这就不满足一致性(Consistency)的要求了.
BASE理论
BASE理论是CAP理论的拓展,BASE是指基本可用(Basically Available)、软状态( Soft State)、最终一致性( Eventual Consistency),核心思想是即使无法做到强一致性(CAP 的一致性就是强一致性),但应用可以采用适合的方式达到最终一致性。
- BA - Basically Available 基本可用 分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
这里的关键词是“部分”和“核心”,实际实践上,哪些是核心需要根据具体业务来权衡。例如登录功能相对注册功能更加核心,注册不了最多影响流失一部分用户,如果用户已经注册但无法登录,那就意味用户无法使用系统,造成的影响范围更大。
S - Soft State 软状态 允许系统存在中间状态,而该中间状态不会影响系统整体可用性。这里的中间状态就是 CAP 理论中的数据不一致。
E - Eventual Consistency 最终一致性 系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。
这里的关键词是“一定时间” 和 “最终”,“一定时间”和数据的特性是强关联的,不同业务不同数据能够容忍的不一致时间是不同的。例如支付类业务是要求秒级别内达到一致,因为用户时时关注;用户发的最新微博,可以容忍30分钟内达到一致的状态,因为用户短时间看不到明星发的微博是无感知的。而“最终”的含义就是不管多长时间,最终还是要达到一致性的状态。
BASE 理论本质上是对 CAP 的延伸和补充,更具体地说,是对 CAP 中 AP 方案的一个补充:
- CAP 理论是忽略延时的,而实际应用中延时是无法避免的。 这一点就意味着完美的 CP 场景是不存在的,即使是几毫秒的数据复制延迟,在这几毫秒时间间隔内,系统是不符合 CP 要求的。因此 CAP 中的 CP 方案,实际上也是实现了最终一致性,只是“一定时间”是指几毫秒而已。
- AP 方案中牺牲一致性只是指发生分区故障期间,而不是永远放弃一致性。 这一点其实就是 BASE 理论延伸的地方,分区期间牺牲一致性,但分区故障恢复后,系统应该达到最终一致性。
酸碱平衡
ACID能够保证事务的强一致性,即数据是实时一致的。这在本地事务中是没有问题的,在分布式事务中,强一致性会极大影响分布式系统的性能,因此分布式系统中遵循BASE理论即可。但分布式系统的不同业务场景对一致性的要求也不同。如交易场景下,就要求强一致性,此时就需要遵循ACID理论,而在注册成功后发送短信验证码等场景下,并不需要实时一致,因此遵循BASE理论即可。因此要根据具体业务场景,在ACID和BASE之间寻求平衡。
分布式事务解决方案
常见的分布式事务解决方案有:
- 二阶段提交(2PC)
- 三阶段提交(3PC)
- 事务补偿(TCC----try,confirm,cancel)
- 本地消息表
- MQ事务(半消息)
- 最大努力通知
- Saga事务
它们有很多优点,当然也都有种种弊端:
例如长时间锁定数据库资源,导致系统的响应不快,并发上不去。
网络抖动出现脑裂情况,导致事物参与者,不能很好地执行协调者的指令,导致数据不一致。
单点故障:例如事物协调者,在某一时刻宕机,虽然可以通过选举机制产生新的Leader,但是这过程中,必然出现问题,而TCC,只有强悍的技术团队,才能支持开发,成本太高。
二阶段提交
二阶段提交协议(Two-phase Commit,即2PC)是常用的分布式事务解决方案,即将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。事务的发起者称协调者,事务的执行者称参与者。
在分布式系统里,每个节点都可以知晓自己操作的成功或者失败,却无法知道其他节点操作的成功或失败。当一个事务跨多个节点时,为了保持事务的原子性与一致性,而引入一个协调者来统一掌控所有参与者的操作结果,并指示它们是否要把操作结果进行真正的提交或者回滚(rollback)。
二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
核心思想就是对每一个事务都采用先尝试后提交的处理方式,处理后所有的读操作都要能获得最新的数据,因此也可以将二阶段提交看作是一个强一致性算法。
处理流程
阶段1:准备阶段
1、协调者向所有参与者发送事务内容,询问是否可以提交事务,并等待所有参与者答复。 2、各参与者执行事务操作,将undo和redo信息记入事务日志中(但不提交事务)。 3、如参与者执行成功,给协调者反馈yes,即可以提交;如执行失败,给协调者反馈no,即不可提交。
阶段2:提交阶段
1、协调者向所有参与者发出正式提交事务的请求(即commit请求)。
2、参与者执行commit请求,并释放整个事务期间占用的资源。
3、各参与者向协调者反馈ack(应答)完成的消息。
4、协调者收到所有参与者反馈的ack消息后,即完成事务提交。
当阶段1所有参与者都返回yes,则提交事务:
反之,若有至少一个参与者返回no,则中止事务:
方案总结
2PC方案实现起来简单,实际项目中使用比较少,主要因为以下问题:
- 性能问题 所有参与者在事务提交阶段处于同步阻塞状态,占用系统资源,容易导致性能瓶颈。
- 可靠性问题 如果协调者存在单点故障问题,如果协调者出现故障,参与者将一直处于锁定状态。
- 数据一致性问题 在阶段2中,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就导致了节点之间数据的不一致。
三阶段提交
三阶段提交协议,是二阶段提交协议的改进版本,与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。
三阶段提交将二阶段的准备阶段拆分为2个阶段,插入了一个preCommit阶段,使得原先在二阶段提交中,参与者在准备之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。
处理流程
阶段1:canCommit
1.协调者向所有参与者发出包含事务内容的canCommit请求,询问是否可以提交事务,并等待所有参与者答复。
2.参与者收到canCommit请求后,如果认为可以执行事务操作,则反馈yes并进入预备状态,否则反馈no。
阶段2:preCommit
1.协调者向所有参与者发出preCommit请求,进入准备阶段。
2.参与者收到preCommit请求后,执行事务操作,将undo和redo信息记入事务日志中(但不提交事务)。
3.各参与者向协调者反馈ack响应或no响应,并等待最终指令。
阶段3:doCommit
1.如果协调者处于工作状态,则向所有参与者发出do Commit请求。
2.参与者收到do Commit请求后,会正式执行事务提交,并释放整个事务期间占用的资源。
3.各参与者向协调者反馈ack完成的消息。
4.协调者收到所有参与者反馈的ack消息后,即完成事务提交。
3PC正常提交的情况:
若某阶段有参与者返回no,或者协调者等待参与者的ack超时都会中止事务:
注意:在3PC的超时机制中,若协调者因等待参与者的ack超时后,会主动中止事务.
但是进入阶段3后,若协调者发出的do Commit请求或abort请求超时后,参与者都会继续执行事务提交.
方案总结
优点
相比二阶段提交,三阶段解决了准备阶段的阻塞问题,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题,阶段3中协调者出现问题时,参与者会继续提交事务。
缺点
数据不一致问题依然存在,当在参与者收到preCommit请求后等待do commite指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,
造成数据不一致。
TCC (Try-Confirm-Cancel)事务
TCC是服务化的二阶段编程模型,其Try、Confirm、Cancel 3个方法均由业务编码实现;
Try操作为一阶段,负责资源的检查和预留。 Confirm操作作为二阶段提交操作,执行真正的业务。 Cancel是预留资源的取消。
TCC事务的Try、Confirm、Cancel可以理解为SQL事务中的Lock、Commit、Rollback。但是Try又不完全等价于Lock,因为它的SQL事务是提交的,Lock状态体现在业务逻辑上.
处理流程
这里以电商下单为方案进行说明,下单过程简单分为 扣减库存 -> 创建订单 两个步骤,库存服务和订单服务分布在两个不同的节点上,两个步骤属于一个分布式事务,要么一起提交,要么全部回滚.
Try阶段
该阶段中,库存服务并不只是简单的扣减库存数量,而是冻结库存.例如,库存有100,下单了2个,剩余的库存是100-2=98,然后还要在另一张表额外记录被冻结的2个库存.
订单服务创建订单后,订单状态不是直接变为"成功",而是"待确认".
之所以这样做,是为了创建一个过渡的中间态,方便在cancel阶段回滚.
未扣减库存-> 冻结库存 -> 扣减库存. 未创建订单 -> 订单待确认 -> 创建订单成功.
这样每个服务都可以分为三个状态,Try阶段使其从第一状态转变为第二状态.
Confirm阶段
根据Try阶段是否全部服务都正常运行来决定是进入Confirm阶段还是Cancel阶段.
若全部正常进行,那么Confirm阶段需要做的就是"提交"正确结果,使其从过渡状态变为最终正确的状态.
例如,库存服务需要删除在Try阶段新增的库存冻结的记录,而订单服务则需要将订单状态变为"成功".
注意:如果Confirm阶段失败, 需要进行重试补偿.
Cancel阶段
若出现某个服务Try阶段失败,则进入Cancel阶段,在逻辑上取消/覆盖Try阶段的行为.
例如,若冻结表有新增记录,则删除该记录并补回相应库存.
若订单状态为"待确认",则修改为"已取消".
方案总结
优点
- 性能提升.准备阶段不会锁住资源(而XA在提交事务前可能锁表).其实TCC严格上讲整体并不是一个事务,而是通过失败后补偿来起到事务的效果.
- 可靠性.解决了XA协议过度依赖协调者而可能引发的单点故障问题.由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。
缺点
TCC的Try、Confirm和Cancel操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。
本地消息表
本地消息表的方案最初是由ebay提出,核心思路是将分布式事务拆分成本地事务进行处理。
方案通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
这样设计可以避免”业务处理成功 + 事务消息发送失败",或"业务处理失败 + 事务消息发送成功"的棘手情况出现,保证2个系统事务的数据一致性。
下面把分布式事务最先开始处理的事务方成为事务主动方,在事务主动方之后处理的业务内的其他事务成为事务被动方。
处理流程
- 主动方写业务表数据,然后写消息表数据.这一步需要包含在同一个事务里面进行.
- 主动方将消息表中的消息发送到消息中间件去通知被动方消费消息.(一般是通过定时器来扫描消息表并发送的)
- 被动方消费完消息,完成自己的业务处理后,也同样通过消息中间件发送已处理的消息给主动方.
- 主动方收到消息,更新消息表为已完成,这样事务就算完成了.
异常情况处理
- 如果主动方写数据或者发消息失败,直接重试即可.
- 如果被动方写数据失败,由于也是本地事务,直接重试.如果因为业务上无法继续进行(如库存不足,余额不足等),则取消尝试,发送消息通知主动方进行补偿/回滚.
- 消息丢了怎么办?重复发送消息,直至收到反馈为止,因此消息接口需要实现幂等,消费消息前需要先判断该消息是否重复.
方案总结
优点
不依赖消息中间件,消息数据可靠.
缺点
- 消息表耦合到业务中,难做成通用性,不可独立伸缩
- 本地消息表是基于数据库来做的,而数据库是要读写磁盘IO的,因此在高并发下是有性能瓶颈的
MQ事务(半消息)
基于MQ的分布式事务方案其实是对本地消息表的封装,将本地消息表基于MQ 内部.
其他方面的协议基本与本地消息表一致。
什么是半消息
由MQ持久化存储,表示将要投递,但需要二次确认才可投递的消息.
处理流程
- 事务消息与普通消息的区别就在于消息生产环节,生产者首先预发送一条消息到MQ(这也被称为发送half消息)
- MQ接受到消息后,先进行持久化,则存储中会新增一条状态为
待发送的消息 - 然后返回ACK给消息生产者,此时MQ不会触发消息推送事件
- 生产者预发送消息成功后,执行本地事务
- 执行本地事务,执行完成后,发送执行结果给MQ
- MQ会根据结果删除或者更新消息状态为
可发送 - 如果消息状态更新为
可发送,则MQ会push消息给消费者,后面消息的消费和普通消息是一样的
注意:由于MQ通常都会保证消息能够投递成功,因此,如果业务没有及时返回ACK结果,那么就有可能造成MQ的重复消息投递问题。因此,对于消息最终一致性的方案,消息的消费者必须要对消息的消费支持幂等,不能造成同一条消息的重复消费的情况。
为什么要预发送消息?不能先执行业务,成功之后再发消息吗?
不能,如果执行完业务还没来得及发消息系统就宕机了,那么系统重启之后无法知道消息有没有发送过.半消息的目的在于让MQ记录需要发送的消息.
异常情况处理
- 半消息发送失败. 半消息的成功发送是必须强制要求的,要么重试到成功为止,要么取消后续的业务操作.
- 主动方执行业务失败. 发送消息通知MQ删除之前的半消息.
- 主动方执行完业务没来得及发送执行结果给MQ系统就宕机了,或者MQ宕机或者网络抖动造成消息超时,MQ无法及时获知执行结果. MQ扫描发现某条消息长期处于"半消息"状态时,需要主动向消息生产者询问该消息的最终状态(Commit 或是 Rollback),该过程即消息回查.同时主动方需要提供对应的回查接口.
方案总结
优点
消息数据独立存储 ,降低业务系统与消息系统之间的耦合.吞吐量取决于MQ.
缺点
消息生产方需要实现回查接口.
一次消息发送需要两次网络请求(half消息 + commit/rollback消息)
Saga事务
Saga事务源于1987年普林斯顿大学的Hecto和Kenneth发表的如何处理long lived transaction(长活事务)论文.
Saga事务核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。
处理流程
Saga事务基本协议如下:
- 每个Saga事务由一系列幂等的有序子事务(sub-transaction) Ti 组成。
- 每个Ti 都有对应的幂等补偿动作Ci,补偿动作用于撤销Ti造成的结果。
事务正常执行完成 T1, T2, T3, ..., Tn,例如:扣减库存(T1),创建订单(T2),支付(T3),依次有序完成整个事务。
事务回滚 T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n,例如:扣减库存(T1),创建订单(T2),支付(T3,支付失败),支付回滚(C3),订单回滚(C2),恢复库存(C1)。
两种恢复策略
向前恢复(forward recovery)
对应于上面第一种执行顺序,适用于必须要成功的场景,发生失败进行重试,执行顺序是类似于这样的:T1, T2, ..., Tj(失败), Tj(重试),..., Tn,其中j是发生错误的子事务(sub-transaction)。该情况下不需要Ci。
向后恢复(backward recovery)
对应于上面提到的第二种执行顺序,其中j是发生错误的子事务(sub-transaction),这种做法的效果是撤销掉之前所有成功的子事务,使得整个Saga的执行结果撤销。
两种实现方式
命令协调(Order Orchestrator):中央协调器负责集中处理事件的决策和业务逻辑排序。
中央协调器(Orchestrator,简称OSO)以命令/回复的方式与每项服务进行通信,全权负责告诉每个参与者该做什么以及什么时候该做什么。
中央协调器必须事先知道执行整个订单事务所需的流程(例如通过读取配置)。如果有任何失败,它还负责通过向每个参与者发送命令来撤销之前的操作来协调分布式的回滚。基于中央协调器协调一切时,回滚要容易得多,因为协调器默认是执行正向流程,回滚时只要执行反向流程即可。
事件编排 (Event Choreography):没有中央协调器(没有单点风险)时,每个服务产生并观察其他服务的事件,并决定是否应采取行动。
在事件编排方法中,第一个服务执行一个事务,然后发布一个事件。该事件被一个或多个服务进行监听,这些服务再执行本地事务并发布(或不发布)新的事件。
当最后一个服务执行本地事务并且不发布任何事件时,意味着分布式事务结束,或者它发布的事件没有被任何Saga参与者听到都意味着事务结束。
事件/编排是实现Saga模式的自然方式,它很简单,容易理解,不需要太多的代码来构建。如果事务涉及2至4个步骤,则可能是非常合适的。
方案总结
命令协调设计的优点和缺点
优点
- 服务之间关系简单,避免服务之间的循环依赖关系,因为Saga协调器会调用Saga参与者,但参与者不会调用协调器
- 程序开发简单,只需要执行命令/回复(其实回复消息也是一种事件消息),降低参与者的复杂性。
- 易维护扩展,在添加新步骤时,事务复杂性保持线性,回滚更容易管理,更容易实施和测试
缺点
- 中央协调器容易处理逻辑容易过于复杂,导致难以维护。
- 存在协调器单点故障风险。
事件/编排设计的优点和缺点
优点
- 避免中央协调器单点故障风险。
- 当涉及的步骤较少服务开发简单,容易实现。
缺点
服务之间存在循环依赖的风险。
当涉及的步骤较多,服务间关系混乱,难以追踪调测。
值得补充的是,由于Saga模型中没有Prepare阶段,因此事务间不能保证隔离性,当多个Saga事务操作同一资源时,就会产生更新丢失、脏数据读取等问题,这时需要在业务层控制并发,例如:在应用层面加锁,或者应用层面预先冻结资源。
各方案比较
- 2PC/3PC 依赖于数据库,能够很好的提供强一致性和强事务性,但相对来说延迟比较高,比较适合传统的单体应用,在同一个方法中存在跨库操作的情况,不适合高并发和高性能要求的场景。
- TCC 适用于执行时间确定且较短,实时性要求高,对数据一致性要求高,比如互联网金融企业最核心的三个服务:交易、支付、账务。
- 本地消息表/MQ事务 都适用于事务中参与方支持操作幂等,对一致性要求不高,业务上能容忍数据不一致到一个人工检查周期,事务涉及的参与方、参与环节较少,业务上有对账/校验系统兜底。
- Saga事务 由于Saga事务不能保证隔离性,需要在业务层控制并发,适合于业务场景事务并发操作同一资源较少的情况。 Saga相比缺少预提交动作,导致补偿动作的实现比较麻烦,例如业务是发送短信,补偿动作则得再发送一次短信说明撤销,用户体验比较差。Saga事务较适用于补偿动作容易处理的场景。
PS: 写完后上脉脉逛了一圈,发现各大厂各大佬的普遍观点是能不用分布式事务,就不用,尽量在本地事务中解决问题,因为分布式事务带来的性能开销和可靠性保障可能都超乎想象.