文章目录
本篇主要内容如下:
前言
我们都在讨论分布式,特别是面试的时候,不管是招初级软件工程师还是高级,都会要求懂分布式,甚至要求用过。传得沸沸扬扬的分布式到底是什么东东,有什么优势?
分布在不同地方的系统或服务,是彼此相互关联的。分布式系统是分工合作的。
案例:
- 比如
Redis
的哨兵机制,可以知道集群环境下哪台Redis
节点挂了。 Kafka
的Leader
选举机制,如果某个节点挂了,会从follower
中重新选举一个leader
出来。(leader
作为写数据的入口,follower
作为读的入口)
对分布式的通俗理解:是一种工作方式,若干独立计算机的集合,这些计算机对于用户来说就像单个相关系统,将不同的业务分布在不同的地方。
主要有两个方面:分片和复制
。分片即将数据拆开存储,如MySQL
的分表,Redis
和ElasticSearch
的分片,Kafka
的分区;所有的分片数据合在一起才是整个组件的数据。而复制则是增加冗余副本,如主从节点。
优势可以从两方面考虑:一个是宏观,一个是微观。
宏观层面
:多个功能模块糅合在一起的系统进行服务拆分,来解耦服务间的调用。
微观层面
:将模块提供的服务分布到不同的机器或容器里,来扩大服务力度。
任何事物都具有两面性,那分布式又会带来哪些问题呢?
- 需要更多优质人才懂分布式,人力成本增加
- 架构设计变得异常复杂,学习成本高
- 运维部署和维护成本显著增加
- 多服务间链路变长,开发排查问题难度加大
- 环境高可靠性问题
- 数据幂等性问题
- 数据的顺序问题
- 等等
讲到分布式不得不知道 CAP
定理和 Base
理论,这里做一个简要介绍。
CAP 定理
在理论计算机科学中,CAP
定理指出对于一个分布式计算系统来说,不可能同时满足以下三点:
-
一致性(
Consistency
)
访问众多节点中任意一个拿到的数据是一样的,且是最新的。 -
可用性(
Availability
)
每次请求都能获取到非错的响应,但不保证获取的数据为最新数据 -
分区容错性(
Partition tolerance
)
分区容错性是CAP
理论中必须满足的,即部署多个节点以避免单点故障,多个分区存储数据提高存储和查询能力。但网络故障不可避免,所以分区容错性必须满足。要么满足AP
,要么满足CP
,CAP
三个无法同时满足。
BASE 理论
BASE
是 Basically Available
(基本可用)、Soft state
(软状态)和 Eventually consistent
(最终一致性)三个短语的缩写。BASE
理论是对 CAP
中 AP
的一个扩展,通过牺牲强一致性来获得可用性,当出现故障时允许部分功能不可用,但要保证核心功能可用,允许数据在一段时间内是不一致的,但最终需要达到一致状态。满足 BASE
理论的事务,我们称之为柔性事务。
基本可用
: 分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。如电商网址交易付款出现问题来,商品依然可以正常浏览。
软状态
: 由于不要求强一致性,所以BASE
允许系统中存在中间状态(也叫软状态),这个状态不影响系统可用性,如订单中的“支付中”、“数据同步中”等状态,待数据最终一致后状态改为“成功”状态。
最终一致性
: 最终一致是指的经过一段时间后,所有节点数据都将会达到一致。如订单的“支付中”状态,最终会变为“支付成功”或者“支付失败”,使订单状态与实际交易结果达成一致,但需要一定时间的延迟、等待。
一、分布式消息队列的坑
消息队列如何做分布式?
将消息队列里面的消息分摊到多个节点(指某台机器或容器)上,所有节点的消息队列之和就包含了所有消息。如Kafka
多个分区中消息的总和是一个Topic
的全部数据。
1. 消息队列的坑之非幂等(重复消费)
幂等性概念
所谓幂等性就是无论多少次操作和第一次的操作结果一样。如果消息被多次消费,很有可能造成数据的不一致。而如果消息不可避免地被消费多次,如果我们开发人员能通过技术手段保证数据的前后一致性,那也是可以接受的。
场景分析
RabbitMQ、RocketMQ、Kafka
消息队列中间件都有可能出现消息重复消费问题。这种问题的解决并不是 MQ
自己保证的,而是需要开发人员来保证。
这几款消息队列都是全球最牛的分布式消息队列,那肯定考虑到了消息的幂等性。我们以 Kafka
为例,看看 Kafka
是怎么保证消息队列的幂等性。
Kafka
有一个 偏移量 的概念,代表着消息的序号,每条消息写到消息队列都会有一个偏移量,消费者消费了数据之后,每过一段固定的时间,就会把消费过的消息的偏移量提交一下,表示已经消费过了,下次消费就从偏移量后面开始消费。
坑:当消费完消息后,还没来得及提交偏移量,系统就被关机了,那么未提交偏移量的消息则会再次被消费。
如下图所示,队列中的数据 A、B、C
,对应的偏移量分别为 100、101、102
,都被消费者消费了,但是只有数据 A
的偏移量 100
提交成功,另外 2
个偏移量因系统重启而导致未及时提交。
重启后,消费者又是拿偏移量 100
以后的数据,从偏移量 101
开始拿消息。所以数据 B
和数据 C
被重复消息。
如下图所示:
避坑指南
-
微信支付结果通知场景
微信官方文档上提到微信支付通知结果可能会推送多次,需要开发者自行保证幂等性。第一次我们可以直接修改订单状态(如支付中 -> 支付成功),第二次就根据订单状态来判断,如果不是支付中,则不进行订单处理逻辑。 -
插入数据库场景
每次插入数据时,先检查下数据库中是否有这条数据的主键id
,如果有,则进行更新操作。 -
写 Redis 场景
Redis
的Set
操作天然幂等性,所以不用考虑Redis
写数据的问题。 -
其他场景方案
生产者发送每条数据时,增加一个全局唯一 id
,类似订单id
。每次消费时,先去Redis
查下是否有这个id
,如果没有,则进行正常处理消息,且将id
存到Redis
。如果查到有这个id
,说明之前消费过,则不要进行重复处理这条消息。
不同业务场景,可能会有不同的幂等性方案,大家选择合适的即可,上面的几种方案只是提供常见的解决思路。总结就是消费方要自己做好幂等设计
。
2. 消息队列的坑之消息丢失
坑:消息丢失会带来什么问题?如果是订单下单、支付结果通知、扣费相关的消息丢失,则可能造成财务损失,如果量很大,就会给甲方带来巨大损失。
那消息队列是否能保证消息不丢失呢?答案:否。主要有三种场景会导致消息丢失。
(1)生产者存放消息的过程中丢失消息
解决方案
-
事务机制(不推荐,异步方式)
对于RabbitMQ
来说,生产者发送数据之前开启RabbitMQ
的事务机制channel.txselect
,如果消息没有进队列,则生产者受到异常报错,并进行回滚channel.txRollback
,然后重试发送消息;如果收到了消息,则可以提交事务channel.txCommit
。但这是一个同步的操作,会影响性能。 -
confirm 机制(推荐,异步方式)
我们可以采用另外一种模式:confirm
模式来解决同步机制的性能问题。每次生产者发送的消息都会分配一个唯一的id
,如果写入到了RabbitMQ
队列中,则RabbitMQ
会回传一个ack
消息,说明这个消息接收成功。如果RabbitMQ
没能处理这个消息,则回调nack
接口。说明需要重试发送消息。 -
也可以
自定义超时时间 + 消息 id
来实现超时等待后重试机制。但可能出现的问题是调用 ack 接口时失败了,所以会出现消息被发送两次的问题,这个时候就需要保证消费者消费消息的幂等性。
事务模式 和 confirm 模式的区别:
- 事务机制是同步的,提交事务后会被阻塞直到提交事务完成后。
confirm
模式异步接收通知,但可能接收不到通知。需要考虑接收不到通知的场景。
(2)消息队列丢失消息
消息队列的消息可以放到内存中,或将内存中的消息转到硬盘(比如数据库)中,一般都是内存和硬盘中都存有消息。如果只是放在内存中,那么当机器重启了,消息就全部丢失了。如果是硬盘中,则可能存在一种极端情况,就是将内存中的数据转换到硬盘的期间中,消息队列出问题了,未能将消息持久化到硬盘。
解决方案
- 创建
Queue
的时候将其设置为持久化。 - 发送消息的时候将消息的
deliveryMode
设置为2
。 - 开启生产者
confirm
模式,可以重试发送消息。
(3)消费者丢失消息
消费者刚拿到数据,还没开始处理消息,结果进程因为异常退出了,消费者没有机会再次拿到消息。
解决方案
- 关闭
RabbitMQ
的自动ack
,每次生产者将消息写入消息队列后,就自动回传一个ack
给生产者。 - 消费者处理完消息再主动
ack
,告诉消息队列我处理完了。
问题: 那这种主动 ack 有什么漏洞了?如果 主动 ack 的时候挂了,怎么办?
则可能会被再次消费,这个时候就需要幂等处理了。
问题: 如果这条消息一直被重复消费怎么办?
则需要有加上重试次数的监测,如果超过一定次数则将消息丢失,记录到异常表或发送异常通知给值班人员。
(4)RabbitMQ 消息丢失总结
(5)Kafka 消息丢失
场景:Kafka 的某个 broker(节点)宕机了,重新选举 leader (写入的节点)。如果 leader 挂了,follower还有些数据未同步完,则 follower 成为 leader 后,消息队列会丢失一部分数据。
解决方案
- 给
topic
设置replication.factor
参数,值必须大于1
,要求每个partition
必须有至少2
个副本。 - 给
kafka
服务端设置min.insyc.replicas
必须大于1
,表示一个leader
至少有一个follower
还跟自己保持联系。
3. 消息队列的坑之消息乱序
坑: 用户先下单成功,然后取消订单,如果顺序颠倒,则最后数据库里面会有一条下单成功的订单。
RabbitMQ 场景:
生产者向消息队列按照顺序发送了 2
条消息,消息1
:增加数据 A
,消息2
:删除数据 A
。
期望结果:数据 A
被删除。
但是如果有两个消费者,消费顺序是:消息2
、消息 1
。则最后结果是增加了数据 A
。
如下,多个消费者消费了同一个队列,导致了消息2
出现了比消息1
还先被消费的情况。
RabbitMQ 解决方案:
- 将
Queue
进行拆分,创建多个内存Queue
,消息1
和 消息2
进入同一个Queue
。 - 创建多个消费者,每一个消费者对应一个
Queue
,即保证一个Queue
只被一个消费者消费,类似Kafka
的分区。
Kafka 场景:
- 创建了
topic
,有3
个partition
。 - 创建一条订单记录,订单
id
作为key
,订单相关的消息都丢到同一个partition
中,同一个生产者创建的消息,顺序是正确的。 - 为了快速消费消息,会创建多个消费者去处理消息,而为了提高效率,每个消费者可能会创建多个线程来并行的去拿消息及处理消息,处理消息的顺序可能就乱序了。如下:虽然同一个分区中的消息保证了有序,但是多个线程在消费,可能导致乱序。
Kafka 解决方案:
- 解决方案和
RabbitMQ
类似,消费者从分区获取数据后,先将消息按Key
分到不同的内存Queue
中,具有相同key
的消息 进同一个Queue
,利用多个内存Queue
,每个线程消费1
个Queue
(额外增加了一个内存Queue
,用于保证虽然有多个线程在消费Kafka
的某个分区,但每个线程都只处理一个内存Queue
,他们是有序的)。 - 或者保证
Kafka
的分区对于一个消费者组,只有一个消费者在消费,且这个消费者只开启了一个线程在处理消息。
4. 消息队列的坑之消息积压
消息积压:消息队列里面有很多消息来不及消费。
-
场景 1: 消费端出了问题,比如消费者都挂了,没有消费者来消费了,导致消息在队列里面不断积压。
-
场景 2: 消费端出了问题,比如消费者消费的速度太慢了,导致消息不断积压。
坑:比如线上正在做订单活动,下单全部走消息队列,如果消息不断积压,订单都没有下单成功,那么将会损失很多交易。
解决方案:解铃还须系铃人
- 修复代码层面消费者的问题,确保后续消费速度恢复或尽可能加快消费的速度。
- 停掉现有的消费者。
- 临时建立好原先
5
倍的Queue
数量。 - 临时建立好原先
5
倍数量的 消费者。 - 将堆积的消息全部转入临时的
Queue
,消费者来消费这些Queue
。
- 对于
Kafka
,一个消费者组中的消费者,一个消费者最多消费一个分区,如果消费者数量小于分区,则可以增加消费者提高消费能力解决消息积压。 - 但是如果消费者数据本身就和分区数量相等了,继续增加消费者无济于事,此时如果是因为消费消息的逻辑比较耗时导致积压,那么可以先新建一个临时
Topic
,分区数设置多一些,而后消费到原消息后直接塞入新的Topic
,因为新的Topic
对应的分区多,所以可以多建一些消费者来消费这个新的Topic
,执行业务逻辑。
5. 消息队列的坑之消息过期失效
坑:RabbitMQ 可以设置过期时间,如果消息超过一定的时间还没有被消费,则会被 RabbitMQ 给清理掉。消息就丢失了。
解决方案:
- 准备好批量重导的程序
- 手动将消息闲时批量重导
6. 消息队列的坑之队列写满
坑:当消息队列因消息积压导致的队列快写满,所以不能接收更多的消息了。生产者生产的消息将会被丢弃。
解决方案:
- 判断哪些是无用的消息,
RabbitMQ
可以进行Purge Message
操作。 - 如果是有用的消息,则需要将消息快速消费,将消息里面的内容转存到数据库。
- 准备好程序将转存在数据库中的消息再次重导到消息队列。
- 闲时重导消息到消息队列。
二、分布式缓存的坑
在高频访问数据库的场景中,我们会在业务层和数据层之间加入一套缓存机制,来分担数据库的访问压力,毕竟访问磁盘 I/O
的速度是很慢的。比如利用缓存来查数据,可能5ms
就能搞定,而去查数据库可能需要 50 ms
,差了一个数量级。而在高并发的情况下,数据库还有可能对数据进行加锁,导致访问数据库的速度更慢。
分布式缓存我们用的最多的就是 Redis
了,它可以提供分布式缓存服务。
1. Redis 数据丢失的坑
哨兵机制
Redis
可以实现利用哨兵机制实现集群的高可用。那什么十哨兵机制呢?
- 英文名:
sentinel
,中文名:哨兵。 - 集群监控:负责主副进程的正常工作。
- 消息通知:负责将故障信息报警给运维人员。
- 故障转移:负责将主节点转移到备用节点上。
- 配置中心:通知客户端更新主节点地址。
- 分布式:有多个哨兵分布在每个主备节点上,互相协同工作。
- 分布式选举:需要大部分哨兵都同意,才能进行主备切换。
- 高可用:即使部分哨兵节点宕机了,哨兵集群还是能正常工作。
坑: 当主节点发生故障时,需要进行主备切换,可能会导致数据丢失。
异步复制数据导致的数据丢失
主节点异步同步数据给备用节点的过程中,主节点宕机了,导致有部分数据未同步到备用节点。而这个从节点又被选举为主节点,这个时候就有部分数据丢失了。
脑裂导致的数据丢失
主节点所在机器脱离了集群网络,实际上自身还是运行着的。但哨兵选举出了备用节点作为主节点,这个时候就有两个主节点都在运行,相当于两个大脑在指挥这个集群干活,但到底听谁的呢?这个就是脑裂。
那怎么脑裂怎么会导致数据丢失呢?如果发生脑裂后,客户端还没来得及切换到新的主节点,连的还是第一个主节点,那么有些数据还是写入到了第一个主节点里面,新的主节点没有这些数据。那等到第一个主节点恢复后,会被作为备用节点连到集群环境,而且自身数据会被清空,重新从新的主节点复制数据。而新的主节点因没有客户端之前写入的数据,所以导致数据丢失了一部分。
避坑指南
- 配置
min-slaves-to-write 1
,表示至少有一个备用节点。 - 配置
min-slaves-max-lag 10
,表示数据复制和同步的延迟不能超过10
秒。最多丢失10
秒的数据
注意:缓存雪崩、缓存穿透、缓存击穿、Redis大Key以及Redis热Key并不是分布式所独有的,单机的时候也会出现。所以不在分布式的坑之列。
三、分库分表的坑
1.分库分表的坑之扩容
分库、分表、垂直拆分和水平拆分
-
分库: 因一个数据库支持的最高并发访问数是有限的,可以将一个数据库的数据拆分到多个库中,来增加最高并发访问数。
-
分表: 因一张表的数据量太大,用索引来查询数据都搞不定了,所以可以将一张表的数据拆分到多张表,查询时,只用查拆分后的某一张表,
SQL
语句的查询性能得到提升。 -
分库分表优势:分库分表后,承受的并发增加了多倍;磁盘使用率大大降低;单表数据量减少,
SQL
执行效率明显提升。
水平拆分
: 把一个表的数据拆分到多个数据库,每个数据库中的表结构不变。用多个库抗更高的并发。比如订单表每个月有500
万条数据累计,每个月都可以进行水平拆分,将上个月的数据放到另外一个数据库。
垂直拆分
: 把一个有很多字段的表,拆分成多张表到同一个库或多个库上面。高频访问字段放到一张表,低频访问的字段放到另外一张表。利用数据库缓存来缓存高频访问的行数据。比如将一张很多字段的订单表拆分成几张表分别存不同的字段(可以有冗余字段)。
分库、分表的方式:
- 根据租户来分库、分表。
- 利用时间范围来分库、分表。
- 利用
ID
取模来分库、分表。
坑:分库分表是一个运维层面需要做的事情,有时会采取凌晨宕机开始升级。可能熬夜到天亮,结果升级失败,则需要回滚,其实对技术团队都是一种煎熬。
怎么做成自动的来节省分库分表的时间?
- 双写迁移方案:迁移时,新数据的增删改操作在新库和老库都做一遍。
- 使用分库分表工具
Sharding-jdbc
来完成分库分表的累活。 - 使用程序来对比两个库的数据是否一致,直到数据一致。
坑: 分库分表看似光鲜亮丽,但分库分表会引入什么新的问题呢?
垂直拆分带来的问题
- 依然存在单表数据量过大的问题。因为可能将有
18
个字段的表分为了有10
个字段和8
个字段的两个表,虽然每个表的字段变少了,但是每个表的记录行数并没有变少。 - 部分表无法关联查询,只能通过接口聚合方式解决,提升了开发的复杂度。
- 分布式事务处理复杂。
水平拆分带来的问题
- 跨库的关联查询性能差。
- 数据多次扩容和维护量大。
- 跨分片的事务一致性难以保证。
2.分库分表的坑之唯一 ID
为什么分库分表需要唯一 ID
如果要做分库分表,则必须得考虑表主键 ID 是全局唯一的
,比如有一张订单表,被分到 A
库和 B
库。如果两张订单表都是从 1
开始递增,那查询订单数据时就错乱了,很多订单ID
都是重复的,而这些订单其实不是同一个订单。
分库的一个期望结果就是将访问数据的次数分摊到其他库,有些场景是需要均匀分摊的,那么数据插入到多个数据库的时候就需要交替生成唯一的ID
来保证请求均匀分摊到所有数据库。
坑: 唯一 ID 的生成方式有 n 种,各有各的用途,别用错了。
生成唯一 ID 的原则
- 全局唯一性
- 趋势递增
- 单调递增
- 信息安全
生成唯一 ID 的几种方式
-
数据库自增
ID
: 每个数据库每增加一条记录,自己的ID
自增1
。多个库的ID
可能重复,这个方案可以直接否掉了,不适合分库分表后的ID
生成。 -
适用
UUID
唯一ID
:UUID
太长、占用空间大。不具有有序性,作为主键时,在写入数据时,不能产生有顺序的append
操作,只能进行insert
操作,导致读取整个B+
树节点到内存,插入记录后将整个节点写回磁盘,当记录占用空间很大的时候,性能很差。 -
获取系统当前时间作为唯一
ID
: 高并发时,1 ms
内可能有多个相同的ID
。 -
Twitter
的snowflake
(雪花算法):Twitter
开源的分布式id
生成算法,64
位的long
型的id
,分为4
部分
基本原理和优缺点:
-
1 bit
:不用,统一为0
-
41 bits
:毫秒时间戳,可以表示69
年的时间。 -
10 bits
:5 bits
代表机房id
,5
个bits
代表机器id
。最多代表32
个机房,每个机房最多32
台机器。也可以根据自身业务特性分配bit
位。 -
12 bits
:同一毫秒内的id
,最多4096
个不同id
,自增模式。
优点:
-
毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。
-
不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成
ID
的性能也是非常高的。 -
可以根据自身业务特性分配
bit
位,非常灵活。
缺点:
强依赖机器时钟,如果机器上时钟回拨(可以搜索 2017
年闰秒 7:59:60
),会导致发号重复或者服务会处于不可用状态。
百度的 UIDGenerator 算法。
基于 Snowflake
的优化算法。借用未来时间和双 Buffer
来解决时间回拨与生成性能等问题,同时结合 MySQL
进行 ID
分配。
优点:解决了时间回拨和生成性能问题。
缺点:依赖 MySQL
数据库。
美团的 Leaf-Snowflake 算法。
如下图:
biz_tag
: 表示业务唯一标签,DB
中维护了不同业务的ID
产生信息。max_id
: 是当前业务从DB
发到所有Leaf
(服务器)的最大ID
。step
: 则为DB
每次向某个Leaf
发多少ID
。
Leaf
可理解成一台服务器,分布式系统下,一个服务会部署多台服务器,即多个Leaf
,每个Leaf
都从DB
获取到了某个号段,如Leaf1
获得了1-1000
号段,那么如果pay_ordertag
订单业务要产生一条新的订单时,假设请求打到了Leaf1
上,那么Leaf1
就可以为该订单记录分配一个ID(1-1000)
之间,而如果请求打到了Leaf2
上,那么Leaf2
就可以为该订单记录分配一个ID(1001-2000)
之间,从而实现了同一个业务的多个服务器产生的ID
互不冲突,保证了唯一。
工作中,也可用Redis
作为号段发放器,即每个Leaf
服务器向Redis
申请号段,由于Redis
是单线程的,可以保证每个服务器申请的号段互不冲突。
实现即使用 RedisKey
作为业务标识,Value
则为当前从Redis
发出去的号最大值,每次有Leaf
来请求时,Redis
的Value
值+Step
,并返回之前的Vulue
值,此时这台Leaf
就获得了新的号段Value~Value+Step
可用了。
获取id
是通过代理服务访问数据库获取一批 id
(号段)。
双缓冲:当前一批的 id
使用 10%
时,再访问数据库获取新的一批 id
缓存起来,等上批的id
用完后直接用。
优点:
-
Leaf
服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景。 -
ID
号码是趋势递增的8byte
的64
位数字,满足上述数据库存储的主键要求。 -
容灾性高:
Leaf
服务内部有号段缓存,即使DB
宕机,短时间内Leaf
仍能正常对外提供服务。 -
可以自定义
max_id
的大小,非常方便业务从原有的ID
方式上迁移过来。 -
即使
DB
宕机,Leaf
仍能持续发号一段时间。 -
偶尔的网络抖动不会影响下个号段的更新。
缺点:
ID
号码不够随机,能够泄露发号数量的信息,不太安全。
怎么选择:一般自己的内部系统,雪花算法足够,如果还要更加安全可靠,可以选择百度或美团的生成唯一 ID 的方案。
四、分布式事务的坑
怎么理解事务?
事务可以简单理解为要么这件事情全部做完,要么这件事情一点都没做,跟没发生一样。
在分布式的世界中,存在着各个服务之间相互调用,链路可能很长,如果有任何一方执行出错,则需要回滚涉及到的其他服务的相关操作。比如订单服务下单成功,然后调用营销中心发券接口发了一张代金券,但是微信支付扣款失败,则需要退回发的那张券,且需要将订单状态改为异常订单。
坑:如何保证分布式中的事务正确执行,是个大难题。
分布式事务的几种主要方式
-
XA
方案(两阶段提交方案) -
TCC
方案(try、confirm、cancel
) -
SAGA
方案 -
可靠消息最终一致性方案
-
最大努力通知方案
XA 方案原理
事务管理器负责协调多个数据库的事务,先问问各个数据库准备好了吗?如果准备好了,则在数据库执行操作,如果任一数据库没有准备,则回滚事务。
适合单体应用,不适合微服务架构。因为每个服务只能访问自己的数据库,不允许交叉访问其他微服务的数据库。
TCC 方案
Try
阶段:对各个服务的资源做检测以及对资源进行锁定或者预留。Confirm
阶段:各个服务中执行实际的操作。Cancel
阶段:如果任何一个服务的业务方法执行出错,需要将之前操作成功的步骤进行回滚。
应用场景:
- 跟支付、交易打交道,必须保证资金正确的场景。
- 对于一致性要求高。
缺点:
但因为要写很多补偿逻辑的代码,且不易维护,所以其他场景建议不要这么做。
Sega 方案
基本原理:业务流程中的每个步骤若有一个失败了,则补偿前面操作成功的步骤。
适用场景:
- 业务流程长、业务流程多。
- 参与者包含其他公司或遗留系统服务。
优势:
- 第一个阶段提交本地事务、无锁、高性能。
- 参与者可异步执行、高吞吐。
- 补偿服务易于实现。
缺点:
- 不保证事务的隔离性。
可靠消息一致性方案
基本原理:
利用消息中间件 RocketMQ
来实现消息事务。
- 第一步:
A
系统发送一个消息到MQ
,MQ
将消息状态标记为prepared
(预备状态,半消息),该消息无法被订阅。 - 第二步:
MQ
响应A
系统,告诉A
系统已经接收到消息了。 - 第三步:
A
系统执行本地事务。 - 第四步:若
A
系统执行本地事务成功,将prepared
消息改为commit
(提交事务消息),B
系统就可以订阅到消息了。 - 第五步:
MQ
也会定时轮询所有prepared
的消息,回调A
系统,让A
系统告诉MQ
本地事务处理得怎么样了,是继续等待还是回滚。 - 第六步:
A
系统检查本地事务的执行结果。 - 第七步:若
A
系统执行本地事务失败,则MQ
收到Rollback
信号,丢弃消息。若执行本地事务成功,则MQ
收到Commit
信号。B
系统收到消息后,开始执行本地事务,如果执行失败,则自动不断重试直到成功。或B
系统采取回滚的方式,同时要通过其他方式通知A
系统也进行回滚。B
系统需要保证幂等性。
最大努力通知方案
基本原理:
- 系统
A
本地事务执行完之后,发送消息到MQ
。 MQ
将消息持久化。- 系统
B
如果执行本地事务失败,则最大努力服务会定时尝试重新调用系统B
,尽自己最大的努力让系统B
重试,重试多次后,还是不行就只能放弃了。转到开发人员去排查以及后续人工补偿。
几种方案如何选择
- 跟支付、交易打交道,优先
TCC
。 - 大型系统,但要求不那么严格,考虑 消息事务或
SAGA
方案。 - 单体应用,建议
XA
两阶段提交就可以了。 - 最大努力通知方案建议都加上,毕竟不可能一出问题就交给开发排查,先重试几次看能不能成功。
写在最后
分布式还有很多坑,这篇只是一个小小的总结,从这些坑中,我们也知道分布式有它的优势也有它的劣势,那到底该不该用分布式,完全取决于业务、时间、成本以及开发团队的综合实力。