在分布式系统中,订单服务和库存服务在不同的服务器中
那么在用户下订单时,我们的服务应该怎么做呢?
-
先扣库存,再建订单
-
先建订单,再扣库存
这两种做法都有问题:
-
先扣库存,在建订单的问题是
- 库存扣减成功,但是订单没有新建成功(有问题,没法归还库存)
-
先建订单,再扣库存的问题是:
-
- 订单创建成功,通知库存扣减失败,回滚创建订单(没有问题)
-
- 订单创建成功,库存扣减也成功了,但库存服务没法通知订单服务,导致订单服务认为库存服务扣减失败,回滚创建订单(有问题,订单没创建,但是库存被扣减了)
-
所以这里无论是先扣减库存还是后扣减库存,都会遇到库存扣减和订单表不一致的问题
那么我们应该怎么做呢?
解决这个问题的方法是使用分布式事务
事务
什么是事务
一组 sql
语句操作一个单元,组内所有的 sql
语句完成一个业务
如果这组 sql
全部执行成功,这个业务是有意义,否则任何一个 sql
执行失败,那么这个业务就是无意义的,组内执行成功的 sql
也是无意义
这是数据库应该会滚到最初的状态,这就是事务
为什么要用事务
- 如果执行失败了,需要回到最初的状态
- 在执行成功之前,对其他用户是不可见的
事务的四大特性
事务的四大特性是 ACID
:
- 原子性(
Atomicity
)- 不可分割,要么都成功,要么都失败
- 一致性(
Consistency
)- 指数据处于一种语义上有意义且正确的状态
- 是对数据可见性的约束,保证在一个事务中的多次操作的数据数据中间状态对其他事务是不可见的
- 隔离性(
Isolation
)- 事务之间是隔离的,互不干扰
- 如果不考虑隔离性,就会存在:脏读、不可重复读、幻读/虚读
mysql
的隔离级别有:读未提交,读已提交,可重复读,串行化
- 持久性(
Durability
)- 事务一旦提交,就是永久性的,不会回滚
如何理解事务的一致性,通过一个例子来说明:A 给 B 转账,会存在 3 状态:
- A 未转账,B 未收到
- A 已转账,B 未收到
- A 已转账,B 已收到
为什么会出现 3 种状态呢?因为 A 转账给 B,需要经过两个步骤:
- A 扣钱
- B 加钱
如果 A 扣钱成功,B 加钱失败,那么就会出现第 2 种状态,但第 2 种状态是不可接受的,因为 A 扣钱了,B 却没收到钱,这就不一致了
也就是说,如果 A 扣钱成功,B 加钱失败,那么 A 扣钱的操作应该回滚,回到第 1 种状态
这么说,那原子性和一致性有什么区别?
- 原子性:关注的是状态,要么都成功,要么都失败
- 一致性:关注的是数据的可见性,即中间状态不可见,只有最初状态和最终状态是可见的
ps:对于单机事务来说,大部分情况无法满足
AICD
,不然怎么会有隔离级别呢?
分布式事务
分布式事务基本不能满足 AICD
为什么会出现分布式事务
- 网络问题:没有发送出去、发送出去了但没收到回复,以为出错了
- 硬件问题
- 网络抖动
- 网络拥塞
- 程序错误
- 代码错误
- 宕机
- 断电
- 系统问题:磁盘满了,硬件坏了
CAP 理论
cap
理论是分布式系统的理论基石:
- 一致性(
Consistency
)- 更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致,这是分布式事务的一致性
- 一致性在并发系统中不可避免
- 对于客户端来说,一致性指的是并发访问时更新过的数据如何获取的问题
- 对于服务端来说,更新后如何复制到整个系统,以保证数据的一致性
- 可用性(
Availability
)- 服务一直可用,不出现访问超时或者不能访问的情况
- 分区容错性(
Partition Tolerance
)- 分布式系统在遇到某个节点或者网络故障时,仍然能够对外提供满足一致性和可用性的服务
- 要求虽然是一个分布式系统,但看上去好像是一个整体
- 如果分布式系统中出现一个或几个服务不可用,剩余的服务仍需满足外部系统的需求
如果是一个分布式系统,一定要满足:分区容错性
一致性和可用性是互斥的,为什么?
一致性可以通过锁来解决问题,但是锁会导致可用性降低,比如同步数据需要 1s,但是可用性的要求是不能超过 100ms,这就出现了矛盾
所以就出现了取舍策略:
CA
:单机数据库,满足一致性和可用性Oracle
、MySQL
CP
:解决的是一致性问题,数据的一致性很重要,当写入的时候一定要等到所有节点都成功- 比如
Redis
、MongoDB
、HBase
等
- 比如
AP
:解决的是可用性问题,如果读不到最新的数据,等一段时间在去读Coach DB
、Cassandra
、DynamoDB
等
base 理论
base
理论是对 cap
中一致性和可用性权衡的结果,来源于大规模互联网分布式实践的总结,是从 cap
中演化而来的
其核心思想是:即使无法做到强一致性,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性
- 基本可用(
Basically Available
)- 在出现不可预知故障的时候,允许损失部分可用性(这不等价于系统不可用)
- 响应时间上的损失
- 功能上的损失:比如降级
- 在出现不可预知故障的时候,允许损失部分可用性(这不等价于系统不可用)
- 软状态(
Soft state
)- 中间状态,且中间状态不影响整体的可用性
- 最终一致性(
Eventual consistency
)- 经过一段时间同步后,最终达到一致的状态
还是用转钱举例子:
A 向 B 转账,告诉 B 钱转成功了,你过一会再查询一下
这时候就引入了中间状态(转账中),这个状态被称为软状态
告诉用户 2 小时后到账,如果没有到账,钱会回到 A 账户中
只要用户接受了这种方式,那么就可以达到最终一致性
分布式系统解决方案
在分布式系统中要保证同时成功和同时失败
常见的分布式事务解决方案:
- 两阶段提交
TCC
补偿模式- 基于本地消息表实现最终一致性
- 最大努力通知
- 基于可靠消息最终一致性方案(最常用)
两阶段提交
两阶段提交又称为 2PC
,是一个非常经典的中心化的原子提交协议
2PC
利用数据库的事务进行实现,有两个阶段:
- 第一阶段: 投票阶段
- 先调用服务,开启事务
- 第二阶段: 提交阶段
- 如果所有服务都成功,那么就提交事务
- 如果有一个服务失败,那么就回滚事务
缺点:
- 性能问题:数据库的事务是会被锁住的,对性能影响比较大
- 单节点故障:一个节点出问题了,导致其他节点也会被卡住
TCC 分布式事务
TCC
分布式事务和 2PC
是一样的
2PC
是基于数据库的事务来实现的,而事务是会加锁的
TCC
就是基于业务实现锁,不使用数据库的事务,这样并发就会比较高
TCC
是 try
、confirm
、cannel
的缩写
TCC
分布式事务步骤:
- 首先需要选择某种
TCC
分布式事务框架,各个微服务就会在这个TCC
分布式事务框架中运行 - 然后原本微服务的一个接口,要改造为
3
个逻辑:try-confirm-cancel
- 先是服务调用链路依次执行
try
函数 - 如果都正常的话,
TCC
分布式事务框架推进执行confirm
函数,完成整个事务 - 如果某个服务
try
函数有问题,TCC
分布式事务框架感知到之后,就会推荐执行各个微服务的cancel
函数,撤销之前执行的各种操作
- 先是服务调用链路依次执行
TCC
分布式事务说白了就是遇到下面情况时:
- 某个服务的数据库宕机了
- 某个服务自己挂了
- 某个服务的
Redis
、ElasticSearch
、MQ
等基础设施故障了 - 某些资源不足了,比如说库存不够了
先来 try
一下,但不把业务逻辑完成,先试试看,各个服务能不能正常运转,能不能先冻结我需要的资源
如果 try
都 ok
,也就是说底层的数据库、Redis
、ElasticSearch
、MQ
都是可以写入数据的,并且你保留好了需要使用的一些资源(比如冻结了一些库存)
最后,如果有一些意外的情况发生了,比如说订单服务突然挂了,然后再次重启,TCC
分布式事务框架是如何保证之前没执行完的分布式事务继续执行?
TCC
要记录一些分布式事务的活动日志,可以放在磁盘上,也可以放在数据库中。保存分布式事务运行的各个阶段的状态- 万一某个服务的
cancel
和confirm
逻辑执行一直失败?TCC
通过保存下来的日志不停的调用cancel
或者confirm
,务必要它成功 - 如果代码没有什么
bug
,有充足的测试的话,一般try
函数正常的话,confirm
和cancel
是可以成功的- 业务逻辑一般是在
try
中 confirm
和cancel
只是确认和取消
- 业务逻辑一般是在
- 万一实在不成功,这是一个小概率事件,那就用邮件通知进行人工干预
优点:
- 解决了跨服务的业务操作原子性问题,例如组合支付,订单减库存等场景
TCC
的本质原理是把数据库的二阶段提交上升到微服务来实现,从而避免了数据库二阶段中锁冲突的长事务低性能风险TCC
异步性能高,它采用了try
先检查,然后异步实现confirm
,真正提交是在confirm
中
缺点:
- 对微服务侵入性强,微服务每个事务都必须实现
try
、confirm
、cancel
3 个方法,维护成本高 - 为了达到事务的一致性要求,
try
、confirm
、cancel
接口必须实现幂等操作 - 由于事务管理器要记录事务日志,必定会损耗一定的性能,并使得整个
TCC
事务时间拉长,建议采用redis
的方式来实现 TCC
需要通过锁来确保数据的一致性,加锁会导致性能不高
基于本地消息表的最终一致性
本地消息表最终一致性的一致性比 TCC
的一致性要弱一点,它是保证最终一致性,不像 TCC
同时成功或者同时失败
下图是基于 MQ
实现最终一致性:
- 订单服务执行自己的逻辑,执行完了之后,把库存扣减和通知的任务放入
MQ
中 - 然后各微服务不停的从
MQ
中拉取任务,执行任务 - 如果各微服务执行任务成功了,通知
MQ
把对应的任务删除- 各微服务执行失败了,不会通知
MQ
那么还会继续从MQ
中拉取任务
- 各微服务执行失败了,不会通知
这里有一个问题,当订单创建成功,但对应的微服务还没有执行完(比如库存扣减服务,通知服务),数据出现了不一致
这就是它的特性:虽然当前数据还没有一致,但最终一定会一致的
这时还有问题:
回到上面图,我们这里是先创建订单,再发送 MQ
消息,那么就有一种情况:
如果订单创建成功了,MQ
消息也发送成功了,但由于网络问题,订单服务没有收到 MQ
的回复,导致订单服务认为 MQ
消息发送失败,回滚了订单
解决这个问题的方法就是本地消息表的最终一致性
引入一张本地表,记录 MQ
的消息发送状态,用定时任务定时扫描这张表查看 MQ
发送状态,这样就可以解决上面的问题
定时扫描会导致消息重复发送给 MQ
,所以各微服务需要实现幂等性
这种方案也是有缺点的:由于关系型数据库的吞吐量和性能方面存在瓶颈,频繁的读写消息会给数据库造成压力。所以,在真正的高并发场景下,该方案也会有瓶颈和限制的
基于可靠消息的最终一致性
此方案和基于本地消息表的最终一致性类似,它用的是 RocketMQ
提供的事务消息
通过上面我们知道,发送消息是不可靠的
我们想要的效果是,消息发送出去后,只要本地服务没有确认,那发送出去的消息不会被消费(这就可以达到,如果本地服务出问题了,随时可取消消息)
此方法解决的是:本地发送出去的消息是可靠的
图中 half message
可以称为半消息,它是不可消费的,只有本地服务确认了,才会变成可消费的消息
比如图中第 4 步,也会遇到网络问题导致 commit/rollback
发送失败
为解决这个问题 Rocket MQ
提供了回查机制,即 Rocket MQ
会定时扫描消息表,如果发现消息状态是 commit/rollback
,但是本地服务没有确认,那么就会回查本地服务,如果本地服务还是没有确认,那么就会重新发送 commit/rollback
消息
当本地服务收到回查消息后,查询本地事务状态,如果本地事务已经成功,那么就返回 commit
,否则返回 rollback
这就保证了消息的可靠性
最大努力通知
此方案一般用来对接第三方服务,因为你不知道第三方服务是怎么实现的,所以只能尽力通知
比如支付宝支付,你支付成功了,但是支付宝支付回调通知你的时候,你的服务挂了,那么支付宝就会不停的通知你,直到你的服务恢复正常
但你的服务什么时候好,支付宝是不知道的,所以支付宝在通知你几次之后就不在通知了
这时候你说,那我怎么知道支付成功了呢?
所以支付宝还需要提供一个查询接口,你可以通过这个接口查询支付状态
就这事最大努力通知
ps:支付宝每次通知的时间不是固定的,它有自己的算法,比如第一次是隔 1s,第二次隔 2s,第三次隔 5s ...,前几次通知不成功,大概率是不会成功了,所以后面间隔的时间会越来越长