分布式相关

一、CAP

Consistency(一致性)、Availability(可用性)、Partition tolerance(分区容错性)

CA 单机

CP 降级

AP放弃强一致性,追求最终一致性

二、BASE

BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually
consistent (最终一致性)三个短语的缩写,是对 CAP 中 AP 的一个扩展。

基本可用:响应时间损失,功能损失(降级)

弱状态:允许系统在不同节点的数据副本之间进行数据同步的过程存在延时

最终一致性:放弃强一致性,追求最终一致性

三、RPC

Remote Procedure Call Protocol

单一架构(ORM关键) ——

垂直架构(MVC关键)——

分布式(RPC关键)——

流动式计算(SOA资源调度与治理中心)

RPC架构组件

在这里插入图片描述

让用户感觉上像调用本地服务一样的调用远程服务

这里写图片描述

Client:调用方。就像上文所描述的那样,最理想的情况是RPC Client在完全不知道有RPC框架存在的情况下发起对远程服务的调用。但实际情况来说Client或多或少的都需要指定RPC框架的一些细节。

Server:远程服务方法的具体实现(在JAVA中就是RPC服务接口的具体实现)。其中的代码是最普通的和业务相关的代码,甚至其接口实现类本身都不知道将被某一个RPC远程客户端调用。

Stub/Proxy:RPC代理存在于客户端,因为要实现客户端对RPC框架“透明”调用,那么客户端不可能自行去管理消息格式、不可能自己去管理网络传输协议,也不可能自己去判断调用过程是否有异常。这一切工作在客户端都是交给RPC框架中的“代理”层来处理的。

Message Protocol:两端都能识别的,共同约定的消息格式。RPC的消息管理层专门对网络传输所承载的消息信息进行编码和解码操作。目前流行的技术趋势是不同的RPC实现,为了加强自身框架的效率都有一套(或者几套)私有的消息格式。

Transfer/Network Protocol:传输协议层负责管理RPC框架所使用的网络协议、网络IO模型。例如Hessian的传输协议基于HTTP(应用层协议);而Thrift的传输协议基于TCP(传输层协议)。传输层还需要统一RPC客户端和RPC服务端所使用的IO模型;

Selector/Processor:存在于RPC服务端,用于服务器端某一个RPC接口的实现的特性(它并不知道自己是一个将要被RPC提供给第三方系统调用的服务)。所以在RPC框架中应该有一种“负责执行RPC接口实现”的角色。包括:管理RPC接口的注册、判断客户端的请求权限、控制接口实现类的执行在内的各种工作。

IDL:实际上IDL(接口定义语言)并不是RPC实现中所必须的。但是需要跨语言的RPC框架一定会有IDL部分的存在。这是因为要找到一个各种语言能够理解的消息结构、接口定义的描述形式。如果您的RPC实现没有考虑跨语言性,那么IDL部分就不需要包括,例如JAVA RMI因为就是为了在JAVA语言间进行使用,所以JAVA RMI就没有相应的IDL。

REST SOAP SOA

REST JSON传输格式

SOAP 基于XML实现安全控制

SOA 面向服务架构,一种粗粒度、松耦合服务架构,服务之间通过简单、精确定义接口进行通讯,不涉及底层编程接口和通讯模型

本质都是提供可支持分布式的基础服务,最大的区别在于他们各自的特点所带来的不同应用场景

RPC的实现基础

动态代理

NIO通信,高效的网络通信,比如一般选择Netty

高效的序列化框架Protobuf

可靠的寻址方式Zookeeper

如果是带会话(状态)的RPC调用,还需要有会话和状态保持的功能

四、分布式ID解决方案

高并发,高可用,高性能

  • 1.UUID 这种方案复杂度最低,但是会影响存储空间和性能
  • 2.利用单机数据库的自增主键,作为分布式ID的生成器,复杂度适中,ID长度较UUID更短,但是受到单机数据库性能的限制,并发量大的时候,该方案也不是最优方案。
  • 3.利用redis,zookeeper的特性来生成id,如:redis的自增命令,zookeeper的顺序节点,这种方案和单机数据库(mysql)性能相比,性能会有所提高,可以适当选用。
  • 4.雪花算法:一切问题如果能直接用算法解决,那是最合适的,利用雪花算法可以生成分布式Id,其底层原理就是通过某台机器在某一毫秒内对某一个数字自增,这种方案也能保证分布式架构中的系统id唯一,但是只能保证趋势递增。

时间范围:2^41 / (1000L * 60 * 60 * 24 * 365) = 69年

工作进程范围:2^10 = 1024

序列号范围:2^12 = 4096,表示1ms可以生成4096个ID。

百度(uid-generator) 美团(Leaf)

时间回拨问题解決:

  1. 回拨时间小的时候,不生成 ID,循环等待到时间点到达。
  2. 上面的方案只适合时钟回拨较小的,如果间隔过大,阻塞等待,肯定是不可取的,因此要么超过一定大小的回拨直接报错,拒绝服务,或者有一种方案是利用拓展位,回拨之后在拓展位上加1就可以了,这样ID依然可以保持唯一。但是这个要求我们提前预留出位数,要么从机器id中,要么从序列号中,腾出一定的位,在时间回拨的时候,这个位置 +1

五、分布式锁的常见解决方案

  • 首先最基本的,我们要保证同一时刻只能有一个应用的一个线程可以执行加锁的方法,或者说获取到锁;(一个应用线程执行

  • 然后我们这个分布式锁可能会有很多的服务器来获取,所以我们一定要能够高性能的获取和释放;(高性能

  • 不能因为某一个分布式锁获取的服务不可用,导致所有服务都拿不到或释放锁,所以要满足高可用要求;(高可用

  • 假设某个应用获取到锁之后,一直没有来释放锁,可能服务本身已经挂掉了,不能一直不释放,导致其他服务一直获取不到锁;(锁失效机制,防止死锁

  • 一个应用如果成功获取到锁之后,再次获取锁也可以成功;(可重入性

  • 在某个服务来获取锁时,假设该锁已经被另一个服务获取,我们要能直接返回失败,不能一直等待。(非阻塞特性

    常见的分布式锁解决方案如下:

    基于数据库实现分布式锁
    基于ZooKeeper实现分布锁
    基于缓存(Redis等)实现分布式锁

基于数据库实现

排它锁

在数据库添加一张方法锁表,用于记录每个方法上锁记录

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
  `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

insert into methodLock(method_name,desc) values (‘method_name’,‘desc’);

MySql的InnoDB引擎查询语句后面增加for update,数据库会对查询结果中的每行都加排他锁
select * from user_info where user_id = xx for update;

缺点:

  • 反复擦写数据库,效率低下
  • 强依赖数据库的可用性,数据库是一个单点。 解决方法:搭建数据库集群
  • 锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。 解决方法:启动一个定时任务,定时清理过期数据。

注意:
1.使用排他锁的时候,一定要有where条件保证检索的数据范围最小,通常需要给user_id列添加索引,保证InnoDB引擎在加锁的时候不用全表扫描,因为只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。获得排它锁的线程即可获得分布式锁,使用完成之后再通过connection.commit()操作来释放锁。

2.一个排他锁长时间不提交,就会占用数据库连接,一旦类似的连接变得多了,就可能把数据库连接池撑爆

乐观锁

基于InnoDB存储引擎的表,通过给表中追加一个版本字段来实现,每次更新数据时,把版本作为条件。因为InnoDB默认会为insert、delete、update加上排他锁,所以,可以在更新数据时,使用如下SQL:

update user_info set value = 100 where id = 1 and version = #{version}

优点:性能比悲观锁高
缺点:会存在大量更新失败异常

解决可重入:加锁时判断记录中是否存在unique_mutex的记录,如果存在且holder_id和当前竞争者id相同,则加锁成功。

锁释放时机:设想如果一个竞争者获取锁时候,进程挂了,此时表中的这条记录就会一直存在,其他竞争者无法加锁。为了解决这个问题,每次加锁之前我们先判断已经存在的记录的创建时间和当前系统时间之间的差是否已经超过超时时间,如果已经超过则先删除这条记录,再插入新的记录。另外在解锁时,必须是锁的持有者来解锁,其他竞争者无法解锁。这点可以通过holder_id字段来判定。

基于zookeeper实现

Zookeeper的数据存储结构就像一棵树,这棵树由节点组成,这种节点叫做Znode。 Zookeeper分布式锁利用了临时顺序节点

ZooKeeper 的树形数据存储结构主要由 4 种节点构成:

持久节点

这是默认的节点类型,一直存在于 ZooKeeper 中

持久顺序节点

在创建节点时,ZooKeeper 根据节点创建的时间顺序对节点进行编号

临时节点

与持久节点不同,当客户端与 ZooKeeper 断开连接后,该进程创建的临时节点就会被删除

临时顺序节点

按时间顺序编号的临时节点。

watch

事件监听:创建节点、删除节点、节点数据修改、节点变更

zk的watch特性可以监听节点变化

获取锁

  • 首先需要在ZK中先创建一个持久节点ParentLock表示一个分布式锁节点。

  • 第一个客户端来获取锁时,就在这个ParentLock节点下创建一个顺序临时节点001-Node,然后查看ParentLock下所有临时顺序节点,判断当前创建节点是否在第一位,如果是,表示加锁成功;

  • 之后第二个客户端来获取锁时,同样在ParentLock节点下创建一个顺序临时节点002-Node,然后判断自己是否在第一位,因为这是第一位是001-Node,所以这是会向排在自己前面的001-Node注册一个Watcher,用来监听001-Node节点,此时该客户端加锁失败,进入等待状态;

  • 当有第三个客户端来时,同理因为新创建的003-Node不在第一位,于是向排在自己前面的002-Node注册一个Watcher,以此类推。

有没有发现,这里是形成了一个链式结构,和JUC中的AQS结构有点相似。

释放锁

释放锁的场景分两种,一种是业务处理完毕,正常释放锁;还有一种是客户端与服务端断开连接。

首先正常释放时,客户端会显式地将ZK中的数据节点删除;比如Client 1在业务处理完成时,将001-Node删除。

而客户端与服务器断开连接的情况,可能发生在客户端获取锁成功后,执行过程中发生异常,或应用崩溃,或网络异常等各种原因导致,这时ZK会自动将对应的Node节点删除。

由于Client 2一直在监听着001-Node节点,当001-Node节点删除后,Client 2会立刻收到通知,这时Client 2会再次查看节点列表,判断自己是否在最前面,如果是,则占有锁,表示加锁成功;

Client 2释放锁之后,Client 3采用同样的方式处理。

要想在Java中使用ZK,官方有提供API包zkClient,使用时引入zookeeper-3.4.6.jarzkclient-0.1.jar即可;

也可使用第三方封装好的工具包,如Curator、Menagerie等。

通过以上我们可以看出,使用ZooKeeper实现分布式锁,基本可以全部满足我们对分布式锁的要求,需要注意的一点是,一定要使用顺序临时节点,而不是临时节点,使用临时节点会存在羊群效应问题。

分布式锁的羊群效应问题

所谓羊群效应,就是在整个分布式锁的竞争过程中,大量的“Watcher 通知”和“子节点列表的获取”操作重复运行,并且大多数节点的 运行结果都是判断出自己当前并不是编号最小的节点,继续等待下一次通知,而不是执行业 务逻辑。 这就会对 ZooKeeper 服务器造成巨大的性能影响和网络冲击。更甚的是,如果同一时间多个节点对应的客户端完成事务或事务中断引起节点消失,ZooKeeper 服务器就会在短时 间内向其他客户端发送大量的事件通知。

解决方案

监听自己前一个节点,来判断是否轮到自己获取锁:

在与该方法对应的持久节点的目录下,为每个进程创建一个临时顺序节点。
每个进程获取所有临时节点列表,对比自己的编号是否最小,若最小,则获得锁。
若本进程对应的临时节点编号不是最小的,则继续判断 :
若本进程为读请求,则向比自己序号小的最后一个写请求节点注册 watch 监听,当 监听到该节点释放锁后,则获取锁;
若本进程为写请求,则向比自己序号小的最后一个读请求节点注册 watch 监听,当 监听到该节点释放锁后,获取锁

总结

使用 ZooKeeper的临时顺序节点+watch机制,可以完美解决设计分布式锁时遇到的各种问题,比如单点故障、不可重入、死锁等问题。虽然 ZooKeeper 实现的分布式锁,几乎能涵盖所有分布式锁的特性,且易于实现,但需要频繁地添加和删除节点,所以性能不如基于缓存实现的分布式锁。

实践

Zookeeper的分布式锁。利用curator加锁和解锁的代码如下:

// 加锁,支持超时,可重入
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
    try {
        return interProcessMutex.acquire(timeout, unit);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return true;
}
// 解锁
public boolean unlock() {
    try {
        interProcessMutex.release();
    } catch (Throwable e) {
        log.error(e.getMessage(), e);
    } finally {
        executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
    }
    return true;
}

基于缓存redis实现

使用Redis做分布式锁也是特别常见的一种选择。并且有多种实现方式

setnx expire

加入过期时间需要和setNx同一个原子操作,在Redis2.8之前我们需要使用Lua脚本达到我们的目的,但是redis2.8之后redis支持nx和ex操作是同一原子操作。

set resourceName value ex 5 nx

可以通过给key设置唯一的uuid,然后释放的时候通过uuid来判断这把锁是否是自己的,防止释放了别人的锁。

Redisson(单机)

分布式锁可能存在锁过期释放,业务没执行完的问题。redisson就解决了这个分布式锁问题。只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。

RedLock算法(集群)

Redis主备切换或者脑裂导致锁被两个线程获取锁。

脑裂:在主从集群中,同时有两个主节点他们都能接收写请求

Redis 是基于主备异步复制协议实现的 Master-Slave 数据同步,若 client A 执行 SET key value EX 10 NX 命令,redis-server 返回给 client A 成功 后,Redis Master 节点突然出现 crash 等异常,这时候 Redis Slave 节点还未收到此命令 的同步。若你部署了 Redis Sentinel 等主备切换服务,那么它就会以 Slave 节点提升为主,此时 Slave 节点因并未执行 SET key value EX 10 NX 命令,因此它收到 client B 发起的加锁的 此命令后,它也会返回成功给 client。

那么在同一时刻,集群就出现了两个 client 同时获得锁,分布式锁的互斥性、安全性就被 破坏了。 除了主备切换可能会导致基于 Redis 实现的分布式锁出现安全性问题,在发生网络分区等 场景下也可能会导致出现脑裂,Redis 集群出现多个 Master,进而也会导致多个 client 同 时获得锁。

Redis 作者为了解决 SET key value [EX] 10 [NX]命令实现分布式锁不安全的问 题,提出了RedLock 算法。它是基于多个独立的 Redis Master 节点的一种实现。client 依次向各个节点申请锁,若能从多数个节点中申请锁成功并满足一些条件限 制,那么 client 就能获取锁成功。

RedLock的核心原理是这样的:

  • 在Redis集群中选出多个Master节点,保证这些Master节点不会同时宕机;
  • 并且各个Master节点之间相互独立,数据不同步;
  • 使用与Redis单实例相同的方法来加锁和解锁。

那么RedLock到底是如何来保证在有节点宕机的情况下,还能安全的呢?

  1. 假设集群中有N台Master节点,首先,获取当前时间戳;
  2. 客户端按照顺序使用相同的key,value依次获取锁,并且获取时间要比锁超时时间足够小;比如超时时间5s,那么获取锁时间最多1s,超过1s则放弃,继续获取下一个;
  3. 客户端通过获取所有能获取的锁之后减去第一步的时间戳,这个时间差要小于锁超时时间,并且要至少有N/2 + 1台节点获取成功,才表示锁获取成功,否则算获取失败;
  4. 如果成功获取锁,则锁的有效时间是原本超时时间减去第三不得时间差;
  5. 如果获取锁失败,则要解锁所有的节点,不管该节点加锁时是否成功,防止有漏网之鱼。

它通过独立的 N 个 Master 节点,避免了使用主备异步复制协议的缺陷,只要多数

Redis 节点正常就能正常工作,显著提升了分布式锁的安全性、可用性。 但是,它的实现建立在一个不安全的系统模型上的,它依赖系统时间,当时钟发生跳跃 时,也可能会出现安全性问题。你要有兴趣的话,可以详细阅读下分布式存储专家 Martin 对RedLock 的分析文章,Redis 作者的也专门写了一篇文章进行了反驳。

缓存etcd分布式锁

etcd介绍

etcd 是一个高可用的分布式K-V系统,可以用来实现各种分布式协同服务,采用raft一致性算法,基于go语言实现,使用bbolt存储引擎,可以处理几个GB的数据。

使用场景

k8s使用etcd来做服务发现与配置信息管理
openstack使用etcd来做配置管理和分布式锁
ROOK使用etcd研发编排引擎

etcd与Redis区别

数据复制上Redis是主备异步复制、etcd使用的是Raft,前者可能会丢数据,为了保证读写一致性,etcd读写性能相比Redis 差距比较大数据分片上Redis有各种集群版解决方案,可以承载上T数据,存储的一般是用户数 据,而etcd定位是个低容量的关键元数据存储,db大小一般不超过8g
存储引擎和API上Redis 内存实现了各种丰富数据结构,而etcd仅是kv API, 使用的是持久化存储boltdb。
etcd实现分布式锁
加锁的过程需要确保安全性、互斥性。比如,当 key 不存 在时才能创建,否则查询相关 key 信息,而 etcd 提供的事务能力正好可以满足我们的诉 求。

事务的特性

etcd的事务由 IF 语句、Then 语句、Else 语句组成。其中在 IF 语句中,支持比较 key 的是修改版本号 mod_revision 和创建版本号 create_revision。 在分布式锁场景,你就可以通过 key 的创建版本号 create_revision 来检查 key 是否已存在,因为一个 key 不存在的话,它的 create_revision 版本号就是 0。

若 create_revision 是 0,你就可发起 put 操作创建相关 key。

实现分布式锁的方案有多种,比如:

可以通过 client 是否成功创建一个固 定的 key,来判断此 client 是否获得锁
可以通过多个 client 创建 prefix 相同,名称 不一样的 key,哪个 key 的 revision 最小,最终就是它获得锁

锁的安全性问题

相比 Redis 基于主备异步复制导致锁的安全性问题,etcd 是基于 Raft 共识算法实现的, 一个写请求需要经过集群多数节点确认。因此一旦分布式锁申请返回给 client 成功后,它一定是持久化到了集群多数节点上,不会出现 Redis 主备异步复制可能导致丢数据的问 题,具备更高的安全性。

锁的活性

etcd的Lease 就是一种活性检测机制,它提供了检测各个客 户端存活的能力。你的业务 client 需定期向 etcd 服务发送"特殊心跳"汇报健康状态,若你 未正常发送心跳,并超过和 etcd 服务约定的最大存活时间后,就会被 etcd 服务移除此 Lease 和其关联的数据。

通过 Lease 机制就优雅地解决了 client 出现 crash 故障、client 与 etcd 集群网络出现隔 离等各类故障场景下的死锁问题。一旦超过 Lease TTL,它就能自动被释放,确保了其他 client 在 TTL 过期后能正常申请锁,保障了业务的可用性。

锁的可用性

当一个持有锁的 client crash 故障后,其他 client 如何快速感知到此锁失效了,快速获得 锁呢,最大程度降低锁的不可用时间呢?

etcd的Watch特性提供了高效的数据监听能力。当其他 client 收到 Watch Delete 事件后,就可快速判断自己是否有资格获得锁,极大减少了锁的不可用时间。

实现

etcd 社区提供了一个名为 concurrency 包帮助你更简单、正确地使用分布式锁、分布式选举。

核心流程

首先通过 concurrency.NewSession 方法创建 Session,本质是创建了一个 TTL 为 10 的 Lease
其次得到 session 对象后,通过 concurrency.NewMutex 创建了一个 mutex 对象,包 含 Lease、key prefix 等信息
然后通过 mutex 对象的 Lock 方法尝试获取锁
最后使用结束,可通过 mutex 对象的 Unlock 方法释放锁

总结对比

四种实现方式:

数据库实现分布式锁最为简单,但是对数据库压力最大;

Redis易于理解,但如果方方面面都考虑到实现较为复杂,比如要考虑到锁到超时时间但是业务没执行完,又或者因为主备切换的过程中出现故障导致锁的不安全性,要考虑使用RedLock算法;

ZK可靠性最高,有封装好的框架,很容易实现分布式锁的 功能,并且几乎解决了数据库锁和缓存式锁的不足,但是因为频繁地添加和删除节点,性能不如Redis;

etcd可以通过它的事务特性MVCC去使用分布式锁,并且因为它基于Raft协议天然保证了安全性,Lease机制会去保证锁的活性,watch机制保证可用性,也是一种很好的实现方案;

对于不同的业务需要的安全程度完全不同,我们需要根据自己的业务场景,通过不同的维度分析,选取最适合自己的方案。

从理解的难易程度角度(从低到高):数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高): Zookeeper >= 缓存 > 数据库
从性能角度(从高到低): 缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低): Zookeeper > 缓存 > 数据库

方案 理解难易程度 实现的复杂度 性能 可靠性
基于数据库 容易 复杂 不可靠
基于缓存(Redis) 一般 一般 可靠
基于Zookeeper 简单 一般 一般

六、分布式事务解决方案

2PC(强一致性) TCC(强一致性) 柔性事务MQ最终一致性,最大努力通知.当前阿里的seata框架可以实现分布式事务,尤其AT模式,代码无侵入。

posted @ 2022-06-30 14:49  浮沉丶随心  阅读(25)  评论(0编辑  收藏  举报