CAP原理指的是,在分布式系统中这三个要素最多只能同时实现两点,不可能三者兼顾。因此在进行分布式架构设计时,必须做出取舍。而对于分布式数据系统,分区容忍性是基本要求,否则就失去了价值。因此设计分布式数据系统,就是在一致性和可用性之间取一个平衡。对于大多数Web应用,其实并不需要强一致性,因此牺牲一致性而换取高可用性,是目前多数分布式数据库产品的方向。
一致性(Consistency):数据在多个副本之间是否能够保持一致的特性。(当一个系统在一致状态下更新后,应保持系统中所有数据仍处于一致的状态)。
可用性(Availability):系统提供的服务必须一直处于可用状态,对每一个操作的请求必须在有限时间内返回结果。
分区容错性(Tolerance of network Partition):分布式系统在遇到网络分区故障时,仍然需要保证对外提供一致性和可用性的服务,除非整个网络都发生故障。
例如,服务器中原本存储的value=0,当客户端A修改value=1时,为了保证数据的一致性,要写到3个服务器中,当服务器C故障时,数据无法写入服务器C,则导致了此时服务器A、B和C的value是不一致的。这时候要保证分区容错性,即当服务器C故障时,仍然能保持良好的一致性和可用性服务,则Consistency和Availability不能同时满足。为什么呢? 如果满足了一致性,则客户端A的写操作value=1不能成功,这时服务器中所有value=0。如果满足可用性,即所有客户端都可以提交操作并得到返回的结果,则此时允许客户端A写入服务器A和B,客户端C将得到未修改之前的value=0结果。
1)Basically Available(基本可用)分布式系统在出现不可预知故障的时候,允许损失部分可用性
2)Soft state(软状态)软状态也称为弱状态,和硬状态相对,是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
2)Eventually consistent(最终一致性)最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
ACID 是传统数据库常用的设计理念,追求强一致性模型。BASE 支持的是大型分布式系统,提出通过牺牲强一致性获得高可用性。 ACID 和 BASE 代表了两种截然相反的设计哲学,在分布式系统设计的场景中,系统组件对一致性要求是不同的,因此 ACID 和 BASE 又会结合使用。
接口的幂等性实际上就是接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。有些接口可以天然的实现幂等性,比如查询接口,对于查询来说,你查询一次和两次,对于系统来说,没有任何影响,查出的结果也是一样。除了查询功能具有天然的幂等性之外,增加、更新、删除都要保证幂等性。
1)全局唯一ID:全局唯一ID就是根据业务的操作和内容生成一个全局ID,在执行操作前先根据这个全局唯一ID是否存在,来判断这个操作是否已经执行。如果不存在则把全局ID,存储到存储系统中,比如数据库、redis等。如果存在则表示该方法已经执行。 从工程的角度来说,使用全局ID做幂等可以作为一个业务的基础的微服务存在,在很多的微服务中都会用到这样的服务,在每个微服务中都完成这样的功能,会存在工作量重复。另外打造一个高可靠的幂等服务还需要考虑很多问题,比如一台机器虽然把全局ID先写入了存储,但是在写入之后挂了,这就需要引入全局ID的超时机制。 使用全局唯一ID是一个通用方案,可以支持插入、更新、删除业务操作。但是这个方案看起来很美但是实现起来比较麻烦,下面的方案适用于特定的场景,但是实现起来比较简单。
2)去重表:这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。
3)插入或更新:这种方法插入并且有唯一索引的情况,比如我们要关联商品品类,其中商品的ID和品类的ID可以构成唯一索引,并且在数据表中也增加了唯一索引。这时就可以使用InsertOrUpdate操作。在mysql数据库中如下:
insert into goods_category (goods_id,category_id,create_time,update_time)
values(#{goodsId},#{categoryId},now(),now())
on DUPLICATE KEY UPDATE
update_time=now()
4)多版本控制:这种方法适合在更新的场景中,比如我们要更新商品的名字,这时我们就可以在更新的接口中增加一个版本号,来做幂等
boolean updateGoodsName(int id,String newName,int version);
在实现时可以如下
update goods set name=#{newName},version=#{version} where id=#{id} and version<${version}
5)状态机控制:这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等。比如订单的创建为0,付款成功为100,付款失败为99 。
在做状态机更新时,我们就这可以这样控制
update `order` set status=#{status} where id=#{id} and status<#{status}
分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。一个大的操作由 N 多的小的操作共同完成。而这些小的操作又分布在不同的服务上。针对于这些操作,要么全部成功执行,要么全部不执行。
1.UUID:时间戳+时钟序列(计数器)+唯一的IEEE机器识别码(比如网卡的MAC地址) 。
缺点:对数据库不友好,因为随机不连续。
2.数据库自增:对于数据库集群模型,要设置不同的数据库起始值不同,但是步长(自增几)相同。
3.Leaf-segment:(美团大众点评的)采用每次获取一个ID区间的方式。比如一次和数据库的交互,就请求到100个id,数据来了直接用。避免每次添加数据都请求一个id,增加了数据库的压力。 也是对数据库自增策略的一个优化。
4.雪花算法:其核心思想是:41位时间戳+10位机器id+12位序列号+符号位(0)。结果是一个长度为64bit的long型的ID。
优点:12位序列号是说每个节点在每毫秒可以产生4096 个ID,并且是递增的。 这样适合于Mysql的聚集索引,索引的连续性也好。
缺点:依赖于时间戳,时间戳是根据机器的时间得到的。比如linux中,如果人为的进行时钟回拨,就可能造成id重复。
● 使用jwt
● 使用cookie (有安全风险)
● 服务器之间进行session同步:保证每个服务器都有session信息,消耗比较大。
● ip绑定策略:比如使用Ngnix进行源地址哈希法的负载均衡,让每一个ip固定访问一个服务器, 但是这种就失去分布式的作用。
● 使用redis存储:是业界最广泛的。 可实现不同服务,不同平台(网页/app),甚至不同语言的session共享。
1)基于数据库做分布式锁--乐观锁(基于版本号)和悲观锁(基于排它锁)
2)基于redis做分布式锁:setnx(key,当前时间+过期时间)和Redlock机制
3)基于zookeeper做分布式锁:临时有序节点来实现的分布式锁,Curator
4)基于 Consul 做分布式锁
基于数据库(MySQL)的分布式锁方案,一般分为3类:基于表记录、乐观锁和悲观锁。
该方法是最简单的,就是直接创建一张锁表。当我们想要获得锁的时候,就可以在该锁表中增加一条记录,想要释放锁的时候就删除锁表的这条记录。
总结:
1.这种锁没有失效时间,一旦释放锁操作失败就会导致锁记录一直在数据库中,其它线程无法获得锁。这个缺陷也很好解决,比如可以做一个定时任务去定时清理。
2.这种锁的可靠性依赖于数据库。建议设置备库,避免单点,进一步提高可靠性。
3.这种锁是非阻塞的。因为插入数据失败之后会直接报错,想要获得锁就需要再次操作。如果需要阻塞式的,可以弄个for循环、while循环之类的,直至INSERT成功再返回。
4.这种锁是非可重入的。因为数据库中锁表的一份记录就是一把锁,想要实现可重入锁,可以在数据库中添加一些字段,比如获得锁的主机信息、线程信息等,那么在再次获得锁的时候可以先查询数据,如果当前的主机信息和线程信息等能被查到的话,可以直接把锁分配给它。
乐观锁大多数是基于数据版本(version)的记录机制实现的。通过对数据库表添加一个 “version”字段来实现的。读数据时会将此版本号一同读出,之后更新数据时会对此版本号加1。在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行更新操作;如果版本号不一致,则执行不会更新。
当然借助更新时间戳(updated_at)也可以实现乐观锁,和采用version字段的方式相似:更新操作执行前先记录当前的更新时间,在提交更新时,检测当前更新时间是否与更新开始时获取的更新时间戳相等。
乐观锁的优点:由于在检测数据冲突时并不依赖数据库本身的锁机制,不会影响请求的性能,当产生并发且并发量较小的时候只有少部分请求会失败。
乐观锁的缺点:需要对表的设计增加额外的字段,增加了数据库的冗余。另外,当应用并发量高的时候,version值在频繁变化,会对数据库产生很大的写压力。并且也会导致大量请求失败,影响系统的可用性。所以数据库乐观锁比较适合并发量不高,并且写操作不频繁的场景。
悲观锁是数据库中自带的。在查询语句后面增加FOR UPDATE,数据库会在查询过程中给数据库表增加悲观锁,也称排他锁。悲观锁就会比较悲观,总是假设最坏的情况,它认为数据的更新在大多数情况下是会产生冲突的。
在使用悲观锁的同时,我们需要注意一下锁的级别。MySQL InnoDB在加锁的时候,只有明确地指定主键(或索引)的才会执行行锁 (只锁住被选取的数据),否则将会执行表锁(将整个数据表单给锁住)。
在使用悲观锁时,我们必须关闭MySQL数据库的自动提交属性(参考下面的示例),因为MySQL默认使用autocommit(自动提交)模式。这样在使用FOR UPDATE获得锁之后可以执行相应的业务逻辑,执行完之后再使用COMMIT来释放锁。
悲观锁优点:可以严格保证数据访问的安全。
悲观锁缺点:即每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞,影响系统可用性。另外,悲观锁使用不当还可能产生死锁的情况。
ZooKeeper是一个分布式的,开放源码的分布式应用程序协调服务,Zookeeper在本质上就像一个文件管理系统。其用类似文件路径的方式管理来监听多个节点(Znode),同时判断当前每个节点上机器的状态(是否宕机、是否断开连接等),从而达到分布式协同的操作。
ZooKeeper 可以根据有序节点+watch实现,实现思路,如:为每个线程生成一个有序的临时节点,为确保有序性,在排序一次全部节点,获取全部节点,每个线程判断自己是否最小,如果是的话,获得锁,执行操作,操作完删除自身节点。如果不是第一个的节点则监听它的前一个节点,当它的前一个节点被删除时,则它会获得锁,以此类推。
1. Redis分布式锁需要不断去尝试获取锁,比较消耗性能。而ZooKeeper分布式锁,获取不到锁会注册个监听器,不需要不断主动尝试获取锁因此性能开销较小;
2. 如果是Redis获取锁的那个客户端bug了或者挂了,那么只能等待超时时间之后才能释放锁;而ZooKeeper的话,因为创建的是临时znode,只要客户端挂了,znode就没了,此时就自动释放锁;
SETNX 是SET IF NOT EXISTS的简写.日常命令格式是SETNX key value,如果 key不存在,则SETNX成功返回1,如果这个key已经存在了,则返回0。
伪代码实现如下:假设某电商网站的某商品做秒杀活动,key可以设置为key_resource_id,value设置任意值
if(jedis.setnx(key_resource_id,lock_value) == 1){ //加锁
expire(key_resource_id,100);//设置过期时间
try {
do something //业务请求
}catch(){
}finally {
jedis.del(key_resource_id); //释放锁
}
}
缺点:setnx和expire两个命令分开了,不是原子操作。如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就永远释放不了,别的线程永远获取不到锁啦。
为了解决发生异常锁得不到释放的场景,可以把过期时间放到setnx的value值里面。如果加锁失败,再拿出value值校验一下即可。加锁代码如下:
long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间
String expiresStr = String.valueOf(expires);
// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key_resource_id, expiresStr) == 1) {
return true;
}
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key_resource_id);
// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)
String oldValueStr = jedis.getSet(key_resource_id, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
return true;
}
}
//其他情况,均返回加锁失败
return false;
}
缺点:
1.过期时间是客户端自己生成的(System.currentTimeMillis()是当前系统的时间),必须要求分布式环境下,每个客户端的时间必须同步。
2.如果锁过期的时候,并发多个客户端同时请求过来,都执行jedis.getSet(),最终只能有一个客户端加锁成功,因此某个客户端加的锁可能被别的客户端所覆盖。
3.该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
SET key value[EX seconds][PX milliseconds][NX|XX]
● EX seconds :设定key的过期时间,时间单位是秒。
● PX milliseconds: 设定key的过期时间,单位为毫秒。
● NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
● XX: 仅当key存在时设置值;
if(jedis.set(key_resource_id,lock_value,"NX","EX",100s)==1){//加锁
try{
dosomething
//业务处理
}catch(){
}finally {
jedis.del(key_resource_id); //释放锁
}
}
缺点:
问题一:锁过期释放了,业务还没执行完。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
问题二:锁被别的线程误删。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。
既然锁可能被别的线程误删,那我们给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下:
if(jedis.set(key_resource_id,uni_request_id,"NX","EX",100s)==1){
//加锁
try {
do something //业务处理
}catch(){
}finally {
//判断是不是当前线程加的锁,是才释放
if (uni_request_id.equals(jedis.get(key_resource_id))) {
jedis.del(lockKey); //释放锁
}
}
}
方案四中还是可能存在锁过期释放,业务没执行完的问题。开源框架Redisson解决了这个问题。先来看下Redisson底层原理图:
只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此Redisson解决了锁过期释放,业务没执行完问题。
前面五种方案都是基于单机版的,其实Redis一般都是集群部署的。为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。
XA协议是一个基于数据库的分布式事务协议,其分为两部分:事务管理器和本地资源管理器。事务管理器作为一个全局的调度者,负责对各个本地资源管理器统一号令提交或者回滚。二阶提交协议(2PC)和三阶提交协议(3PC)就是根据此协议衍生出来而来。主流的诸如Oracle、MySQL等数据库均已实现了XA接口。 XA接口是双向的系统接口,在事务管理器(Transaction Manager)以及一个或多个资源管理器(Resource Manager)之间形成通信桥梁。也就是说,在基于XA的一个事务中,我们可以针对多个资源进行事务管理,例如一个系统访问多个数据库,或即访问数据库、又访问像消息中间件这样的资源。这样我们就能够实现在多个数据库和消息中间件直接实现全部提交、或全部取消的事务。XA规范不是java的规范,而是一种通用的规范。
两段提交顾名思义就是要进行两个阶段的提交:
第一阶段,准备阶段(投票阶段);
第二阶段,提交阶段(执行阶段);
二阶段提交看似能够提供原子性的操作,但它存在着严重的缺陷:
1)网络抖动导致的数据不一致:第二阶段中协调者向参与者发送commit命令之后,一旦此时发生网络抖动,导致一部分参与者接收到了commit请求并执行,可其他未接到commit请求的参与者无法执行事务提交。进而导致整个分布式系统出现了数据不一致。
2)超时导致的同步阻塞问题:2PC中的所有的参与者节点都为事务阻塞型,当某一个参与者节点出现通信超时,其余参与者都会被动阻塞占用资源不能释放。 3)单点故障的风险:由于严重的依赖协调者,一旦协调者发生故障,而此时参与者还都处于锁定资源的状态,无法完成事务commit操作。虽然协调者出现故障后,会重新选举一个协调者,可无法解决因前一个协调者宕机导致的参与者处于阻塞状态的问题。
三段提交(3PC)是对两段提交(2PC)的一种升级优化,3PC在2PC的第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前,各参与者节点的状态都一致。同时在协调者和参与者中都引入超时机制,当参与者各种原因未收到协调者的commit请求后,会对本地事务进行commit,不会一直阻塞等待,解决了2PC的单点故障问题,但3PC还是没能从根本上解决数据一致性的问题。
3PC的三个阶段分别是CanCommit、PreCommit、DoCommit: CanCommit:协调者向所有参与者发送CanCommit命令,询问是否可以执行事务提交操作。如果全部响应YES则进入下一个阶段。 PreCommit:协调者向所有参与者发送PreCommit命令,询问是否可以进行事务的预提交操作,参与者接收到PreCommit请求后,如参与者成功的执行了事务操作,则返回Yes响应,进入最终commit阶段。一旦参与者中有向协调者发送了No响应,或因网络造成超时,协调者没有接到参与者的响应,协调者向所有参与者发送abort请求,参与者接受abort命令执行事务的中断。 DoCommit:在前两个阶段中所有参与者的响应反馈均是YES后,协调者向参与者发送DoCommit命令正式提交事务,如协调者没有接收到参与者发送的ACK响应,会向所有参与者发送abort请求命令,执行事务的中断。
TCC(Try-Confirm-Cancel)又被称补偿事务,TCC与2PC的思想很相似,事务处理流程也很相似,但2PC是应用于在DB层面,TCC则可以理解为在应用层面的2PC,是需要我们编写业务逻辑来实现。 TCC它的核心思想是:"针对每个操作都要注册一个与其对应的确认(Try)和补偿(Cancel)"。 还拿下单扣库存解释下它的三个操作:
Try阶段:下单时通过Try操作去扣除库存预留资源。
Confirm阶段:确认执行业务操作,在只预留的资源基础上,发起购买请求。
Cancel阶段:只要涉及到的相关业务中,有一个业务方预留资源未成功,则取消所有业务资源的预留请求。
1)解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。
2)同步阻塞:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。
3)数据一致性,有了补偿机制之后,由业务活动管理器控制一致性。
总之,TCC 就是通过代码人为实现了两阶段提交,不同的业务场景所写的代码都不一样,并且很大程度的增加了业务代码的复杂度。因此,这种模式并不能很好地被复用。
应用侵入性强:TCC由于基于在业务层面,至使每个操作都需要有try、confirm、cancel三个接口。
开发难度大:代码开发量很大,要保证数据一致性confirm和cancel接口还必须实现幂等性。