一 分布式系统特点
现今互联网界, 分布式系统和微服务架构盛行. 业界著名的 CAP 理论也告诉我们, 在设计和实现一个分布式系统时, 需要将数据一致性, 系统可用性和分区容忍性放在一起考虑.
1,CAP 理论
在分布式系统中, 一致性 (Consistency), 可用性(Availability) 和分区容忍性(Partition Tolerance)3 个要素最多只能同时满足两个, 不可兼得. 其中, 分区容忍性又是不可或缺的.
一致性: 分布式环境下多个节点的数据是否强一致.
可用性: 分布式服务能一直保证可用状态. 当用户发出一个请求后, 服务能在有限时间内返回结果.
分区容忍性: 特指对网络分区的容忍性.
举例: Cassandra,Dynamo 等, 默认优先选择 AP, 弱化 C;HBase,MongoDB 等, 默认优先选择 CP, 弱化 A.
2,BASE 理论
核心思想:
基本可用(Basically Available): 指分布式系统在出现故障时, 允许损失部分的可用性来保证核心可用.
软状态(Soft State): 指允许分布式系统存在中间状态, 该中间状态不会影响到系统的整体可用性.
最终一致性(Eventual Consistency): 指分布式系统中的所有副本数据经过一定时间后, 最终能够达到一致的状态.
二 一致性模型
数据的一致性模型可以分成以下 3 类:
强一致性: 数据更新成功后, 任意时刻所有副本中的数据都是一致的, 一般采用同步的方式实现.
弱一致性: 数据更新成功后, 系统不承诺立即可以读到最新写入的值, 也不承诺具体多久之后可以读到.
最终一致性: 弱一致性的一种形式, 数据更新成功后, 系统不承诺立即可以返回最新写入的值, 但是保证最终会返回上一次更新操作的值.
分布式系统数据的强一致性, 弱一致性和最终一致性可以通过 Quorum NRW 算法分析.
三 分布式事务
分布式事务的目的是保障分布式存储中数据一致性, 而跨库事务会遇到各种不可控制的问题, 如个别节点宕机, 像单机事务一样的 ACID 是无法奢望的.
1,Two/Three Phase Commit
2PC, 中文叫两阶段提交. 在分布式系统中, 每个节点虽然可以知晓自己的操作时成功或者失败, 却无法知道其他节点的操作的成功或失败. 当一个事务跨越多个节点时, 为了保持事务的 ACID 特性, 需要引入一个作为协调者的组件来统一掌控所有节点 (称作参与者) 的操作结果并最终指示这些节点是否要把操作结果进行真正的提交. 两阶段提交的算法如下:
第一阶段:
协调者会问所有的参与者结点, 是否可以执行提交操作.
各个参与者开始事务执行的准备工作: 如: 为资源上锁, 预留资源.
参与者响应协调者, 如果事务的准备工作成功, 则回应 "可以提交", 否则回应 "拒绝提交".
第二阶段:
如果所有的参与者都回应 "可以提交", 那么, 协调者向所有的参与者发送 "正式提交" 的命令. 参与者完成正式提交, 并释放所有资源, 然后回应 "完成", 协调者收集各结点的 "完成" 回应后结束这个 Global Transaction.
如果有一个参与者回应 "拒绝提交", 那么, 协调者向所有的参与者发送 "回滚操作", 并释放所有资源, 然后回应 "回滚完成", 协调者收集各结点的 "回滚" 回应后, 取消这个 Global Transaction.
两段提交最大的问题就是第 3)项, 如果第一阶段完成后, 参与者在第二阶没有收到决策, 那么数据结点会进入 "不知所措" 的状态, 这个状态会 block 住整个事务. 也就是说, 协调者 Coordinator 对于事务的完成非常重要, Coordinator 的可用性是个关键.
因些, 我们引入三段提交, 三段提交在 Wikipedia 上的描述如下, 他把二段提交的第一个段 break 成了两段: 询问, 然后再锁资源. 最后真正提交. 三段提交的核心理念是: 在询问的时候并不锁定资源, 除非所有人都同意了, 才开始锁资源. 但三阶段提交也存在一些缺陷, 要彻底从协议层面避免数据不一致, 可以采用 Paxos 或者 Raft 算法.
目前两阶段提交, 三阶段提交存在如下的局限性, 并不适合在微服务架构体系下使用:
所有的操作必须是事务性资源(比如数据库, 消息队列, EJB 组件等), 存在使用局限性(微服务架构下多数使用 HTTP 协议), 比较适合传统的单体应用;
由于是强一致性, 资源需要在事务内部等待, 性能影响较大, 吞吐率不高, 不适合高并发与高性能的业务场景;
2,Try Confirm Cancel(TCC)
一个完整的 TCC 业务由一个主业务服务和若干个从业务服务组成, 主业务服务发起并完成整个业务活动, TCC 模式要求从服务提供三个接口: Try,Confirm,Cancel.
Try: 完成所有业务检查, 预留必须业务资源.
Confirm: 真正执行业务, 不作任何业务检查; 只使用 Try 阶段预留的业务资源; Confirm 操作满足幂等性.
Cancel: 释放 Try 阶段预留的业务资源; Cancel 操作满足幂等性.
整个 TCC 业务分成两个阶段完成:
第一阶段: 主业务服务分别调用所有从业务的 try 操作, 并在活动管理器中登记所有从业务服务. 当所有从业务服务的 try 操作都调用成功或者某个从业务服务的 try 操作失败, 进入第二阶段.
第二阶段: 活动管理器根据第一阶段的执行结果来执行 confirm 或 cancel 操作. 如果第一阶段所有 try 操作都成功, 则活动管理器调用所有从业务活动的 confirm 操作. 否则调用所有从业务服务的 cancel 操作.
与 2PC 比较:
位于业务服务层而非资源层.
没有单独的准备 (prepare) 阶段, Try 操作兼备资源操作与准备能力.
Try 操作可以灵活选择业务资源的锁定粒度.
开发成本较高.
缺点:
Canfirm 和 Cancel 的幂等性很难保证.
这种方式缺点比较多, 通常在复杂场景下是不推荐使用的, 除非是非常简单的场景, 非常容易提供回滚 Cancel, 而且依赖的服务也非常少的情况.
这种实现方式会造成代码量庞大, 耦合性高. 而且非常有局限性, 因为有很多的业务是无法很简单的实现回滚的, 如果串行的服务很多, 回滚的成本实在太高.
3, 异步确保最终一致性
核心思想:
eBay 的架构师 Dan Pritchett, 曾在一篇解释 BASE 原理的论文Base:An Acid Alternative中提到一个 eBay 分布式系统一致性问题的解决方案. 它的核心思想是将需要分布式处理的任务通过消息或者日志的方式来异步执行, 消息或日志可以存到本地文件, 数据库或消息队列, 再通过业务规则进行失败重试, 它要求各服务的接口是幂等的.
本地消息表
其基本的设计思想是将远程分布式事务拆分成一系列的本地事务. 如果不考虑性能及设计优雅, 借助关系型数据库中的表即可实现.
举个经典的跨行转账的例子来描述.
第一步伪代码如下, 扣款 100, 通过本地事务保证了凭证消息插入到消息表中:
- begin transaction:
- update User set account = account - 100 where userId = 'A'
- insert into message(msgId, userId, amount, status) values('123','A', 100, 1)
- commit transaction
第二步, 通知对方银行账户上加 100 了. 那问题来了, 如何通知到对方呢?
通常采用两种方式:
采用时效性高的 MQ, 由对方订阅消息并监听, 有消息时自动触发事件.
采用定时轮询扫描的方式, 去检查消息表的数据.
两种方式其实各有利弊, 仅仅依靠 MQ, 可能会出现通知失败的问题. 而过于频繁的定时轮询, 效率也不是最佳的(90% 是无用功). 所以, 我们一般会把两种方式结合起来使用.
解决了通知的问题, 又有新的问题了. 万一这消息有重复被消费, 往用户帐号上多加了钱, 那岂不是后果很严重? 其实我们可以消息消费方也通过一个 "消费状态表" 来记录消费状态. 在执行 "加款" 操作之前, 检测下该消息 (提供标识) 是否已经消费过, 消费完成后, 通过本地事务控制来更新这个 "消费状态表". 这样子就避免重复消费的问题:
- get msgId = '123';
- check if mgsId is in message_applied(msgId);
- if not applied:
- begin transaction:
- update User set account = account + 100 where userId = 'B'
- insert into message_applied(msgId) values('123')
- commit transaction
上诉的方式是一种非常经典的实现, 基本避免了分布式事务, 实现了 "最终一致性". 但是, 关系型数据库的吞吐量和性能方面存在瓶颈, 频繁的读写消息会给数据库造成压力. 所以, 在真正的高并发场景下, 该方案也会有瓶颈和限制的.
MQ(非事务消息)
通常情况下, 在使用非事务消息支持的 MQ 产品时, 我们很难将业务操作与对 MQ 的操作放在一个本地事务域中管理. 还是以上述提到的 "跨行转账" 为例, 我们很难保证在扣款完成之后对 MQ 投递消息的操作就一定能成功. 这样一致性似乎很难保证.
我们来分析下可能的情况:
操作数据库成功, 向 MQ 中投递消息也成功, 皆大欢喜.
操作数据库失败, 不会向 MQ 中投递消息了.
操作数据库成功, 但是向 MQ 中投递消息时失败, 向外抛出了异常, 刚刚执行的更新数据库的操作将被回滚.
从上面分析的几种情况来看, 貌似问题都不大的. 那么我们来分析下消费者端面临的问题:
消息出列后, 消费者对应的业务操作要执行成功. 如果业务执行失败, 消息不能失效或者丢失. 需要保证消息与业务操作一致.
尽量避免消息重复消费. 如果重复消费, 也不能因此影响业务结果.
如何保证消息与业务操作一致, 不丢失?
主流的 MQ 产品都具有持久化消息的功能. 如果消费者宕机或者消费失败, 都可以执行重试机制的(有些 MQ 可以自定义重试次数).
如何避免消息被重复消费造成的问题?
保证消费者调用业务的服务接口的幂等性.
通过消费日志或者类似状态表来记录消费状态, 便于判断(建议在业务上自行实现, 而不依赖 MQ 产品提供该特性).
这种方式比较常见, 性能和吞吐量是优于使用关系型数据库消息表的方案. 如果 MQ 自身和业务都具有高可用性, 理论上是可以满足大部分的业务场景的. 不过在没有充分测试的情况下, 不建议在交易业务中直接使用.
MQ(事务消息)
举个例子, Bob 向 Smith 转账, 那我们到底是先发送消息, 还是先执行扣款操作?
好像都可能会出问题. 如果先发消息, 扣款操作失败, 那么 Smith 的账户里面会多出一笔钱. 反过来, 如果先执行扣款操作, 后发送消息, 那有可能扣款成功了但是消息没发出去, Smith 收不到钱. 除了上面介绍的通过异常捕获和回滚的方式外, 还有没有其他的思路呢?
下面以阿里巴巴的 RocketMQ 中间件为例, 分析下其设计和实现思路.
RocketMQ 第一阶段发送 Prepared 消息时, 会拿到消息的地址, 第二阶段执行本地事物, 第三阶段通过第一阶段拿到的地址去访问消息, 并修改状态. 细心的读者可能又发现问题了, 如果确认消息发送失败了怎么办? RocketMQ 会定期扫描消息集群中的事物消息, 这时候发现了 Prepared 消息, 它会向消息发送者确认, Bob 的钱到底是减了还是没减呢? 如果减了是回滚还是继续发送确认消息呢? RocketMQ 会根据发送端设置的策略来决定是回滚还是继续发送确认消息. 这样就保证了消息发送与本地事务同时成功或同时失败. 如下图:
各大知名的电商平台和互联网公司, 几乎都是采用类似的设计思路来实现 "最终一致性" 的. 这种方式适合的业务场景广泛, 而且比较可靠. 不过这种方式技术实现的难度比较大. 目前主流的开源 MQ(ActiveMQ,RabbitMQ,Kafka)均未实现对事务消息的支持, 所以需二次开发或者新造轮子. 比较遗憾的是, RocketMQ 事务消息部分的代码也并未开源, 需要自己去实现.
总结:
阅读了不少这方面的文章, 在此基础上, 总结一下分布式事务一致性的解决方案. 分布式系统的事务一致性本身就是一个技术难题, 目前没有一种很简单很完美的方案能够应对所有场景. 分布式系统的一个难点就是因为 "网络通信的不可靠", 只能通过 "确认机制","重试机制","补偿机制" 等各方面来解决一些问题. 在综合考虑可用性, 性能, 实现复杂度等各方面的情况上, 比较好的选择是 "异步确保最终一致性", 只是具体实现方式上有一些差异.
来源: http://www.jianshu.com/p/fe5fded941ef