分布式系统的数据一致性
CAP
C:一致性
A:可用性
P:分区容错性
在分布式系统中,分区容错性是基本的,所以我们一般会在CP和AP中进行选择。
ZK保持的就是CP,保证了集群的一致性,但为了保证一致性,可能集群会出现一段时间的不可用;
NS保持的就是AP,保持了可用性,但集群数据可能会不一致,可以通过重试机制来解决。
BASE
BASE理论是:基本可用、软状态、最终一致性 这三点。
分布式事务
为什么需要分布式事务
一个大的操作,这个大的操作中包含多个小的操作,事务就是让整个大的操作要么都成功,要么都失败,最终保证数据的一致性。
在本地事务中,因为只涉及一个数据库,可以在数据库层面就进行保障,InnoDB就提供了事务的保证。
但在分布式系统中,这多个小的操作可能在多个应用,涉及多个数据库,那就无法通过本地事务来保证整个大的操作要么都成功,要么都失败了。所以需要分布式事务。
我们处理分布式事务问题,较常用的有三种方案:基于MQ消息、TCC、二阶段提交。
基于MQ消息
这种方案主要是利用MQ提供的消息可靠性来实现的,就算消费端服务处理本地事务失败了,但消息也不会丢失,会通过重试的机制去重试,就算一直重试失败也会放入死信队列中,我们还是可以追查到这条消息。
除了消息可靠性之外还有一个实现的基础是MQ支持事务消息,事务消息就是一个半消息,生产者发到MQ后,并不会被消费者消费,只有事务消息提交之后才会被消费。
RocketMQ实现分布式事务的步骤一般如下:
- 生产者发送一个事务消息,然后Broker持久化之后,会返回ACK确认Broker收到了消息,如果没有返回那么事务之间失败了;
- 生产者执行本地事务,如果执行失败了,那么告诉Broker删除这条事务消息,这次事务失败结束,如果执行成功,那么提交之前的事务消息,这样消费者就可以消费了,这里其实已经完成了事务的一半了,剩下的事务的保证就交给MQ的消息可靠了;
- 消费者消费消息,成功的话那这次事务就成功了,如果失败的话,RocketMQ是会通过重试机制,让消费者多次尝试处理,如果一直不成功,那么会把这条消息放入死信队列,这时候两边数据虽然是不一致的,但因为消息还在的,可能就需要开发介入,看下一直失败的原因。
如果生产者本地事务处理成功,消息发送失败,那么就会出现数据不一致的问题,RocketMQ提供了一个回查的接口,事务消息的状态如果不是rollback或commit,就回查生产者本地事务处理的结果。PS:对于复杂的业务可以用一张消息发送表来记录事务消息的处理结果,不然回查的时候比较麻烦。
但不是所有的MQ都支持事务消息,这个时候可以用本地消息表来充当事务消息。
生产者方维护一张本地消息表,然后把执行本地事务和向本地消息表中插入一条数据放在一个事务中,当本地事务执行成功的同时,会插入一条“待发送”的数据。然后用一个定时任务一直去扫描消息表“待发送”的数据,有就给他发到MQ,只要发到了MQ,后面的流程跟用事务消息就一样了。PS:这里本地消息表有中间状态“待发送”是关键。
缺点:基于MQ可靠消息来实现数据的最终一致,这种分布式事务方案,有个主要的问题是,消费者消费失败(业务上的失败会导致一直处理不成功),这时两边数据其实是存在不一致的。适合对最终一致性敏感低的场景。
TCC
这种方案我认为实现的本质是:当事务执行失败的时候,提供一种补偿方式或者说回滚方式来恢复数据。
一般来说,分为3个阶段:
- try阶段。对事务涉及的数据进行锁定;
- confirm阶段。执行事务,操作锁定的数据;
- cancel阶段。如果中间有异常,那么进行回滚补偿,将涉及的数据恢复到锁定前的状态。
缺点:和业务深度耦合,难以复用。TCC也是柔性事务,保障最终一致性,只是失败能够立即回滚,适合支付这种最终一致性比较敏感的场景。
二阶段提交
这种方案的本质我认为是:有一个全局协调的管理者,他来监控所有事务涉及的参与者,如果所有的参与者执行本地事务都成功了,事务就成功;只要有一个参与者失败,整个事务就失败。
一般来说,分为2个阶段:
- 预提交阶段。所有事务参与者向管理者发一个预提交消息,我理解的预提交消息就是在事务执行之前,先看看各方都在不在线,不在线那直接就可以结束事务了,所以这个预提交消息的内容应该不重要。然后管理者如果收全了预提交消息,那么就可以告诉事务参与者进行本地事务处理了。
- 提交阶段。事务参与者执行本地事务,如果有一个失败,那么管理者就会让所有的参与者进行回滚。
缺点:性能差、管理者需要考虑单点故障、脑裂问题。二阶段提交是保障强一致性的。
分布式锁
在一个JVM中,我们可以通过synchronized、Lock等来实现对资源的加锁,但分布式系统在不同节点上,这些方式肯定就失效了,这时候就需要用到分布式锁。但其实两种的思想还是差不多的,JVM中的锁,锁的对象是内存中的一个变量;分布式锁,如果用zk,锁的是zk的节点,如果用redis,锁的是key。所以我感觉,锁的核心就是提供一个能确保唯一的资源作为锁对象,然后让竞争锁的这些去尝试获取这个唯一资源,获取到了就拿到了锁。
zk锁
zk锁主要基于节点唯一性和事件通知机制来实现。竞争锁的服务都尝试创建节点,哪个创建成功哪个就获取到了锁,没有成功的,注册一个监听器,监听这个节点的删除事件,这样这个节点锁释放以后,别的服务可以再尝试获取锁。
这种方式会有一个惊群效应的问题,也就是,锁释放以后,所有监听的服务都又去尝试获取锁,但最后只能有一个能获取到。如果获取锁的服务很多,就很浪费性能。
解决惊群效应的方案是利用zk的临时有序节点,每个服务获取锁,都会创建一个节点,然后如果自己是最小的节点,那么就能获取到锁,如果不是,那么就监听比他大的节点的删除事件。有点公平锁的意思,先来的先排队,然后监听前面的释放锁的信息。
zk锁应该不会有死锁问题,因为用的都是临时节点,客户端挂了,节点也就销毁了。但是zk锁性能差,因为要创建删除节点的操作比较消耗性能。
redis锁
redis锁主要通过setnx命令来实现,setnx命令如果key创建成功返回true,获取到了锁;如果key已存在返回false,就没获取到锁。通过这种方式就能保证锁只能被一个服务获取到。
我们使用setnx命令,除了key之外,还会设置超时时间和value。设置超时时间是为了防止,锁没有被释放,造成死锁的情况;设置value值是为了防止释放了别人的锁的情况,这种情况是这样的:获取到锁的服务如果在超时时间内没有执行完,那超时时间之后锁就会自动释放了,这时候别的服务又来获取锁,但原来的服务执行完了,又来释放锁,这时候如果没有做value的校验,那么就可以把这个锁释放了,锁就又可以被别的服务获取了,这样就出现了多个服务都持有锁的情况了。
但是setnx命令这种方式存在单点故障的问题,在redis集群中,根据hash槽计算之后,key只会存在一个主从组中,如果这个主从挂了,那么锁就会出现问题。为了解决单点故障问题,redis提供了redLock,其实现原理是:集群中所有的主节点执行setnx命令,超过半数的成功才算成功。
雪花算法
分库分表的时候,或者说分布式系统要生成全局唯一的ID,就可以用雪花算法。
雪花算法就是用一个64bit的long型数字来表示全局唯一的ID,long数字里面会包含时间戳、机器id等信息,这样就能保证ID的唯一性,并且因为时间戳的关系,ID是有序的。