https://github.com/doocs/advanced-java/tree/master/docs/distributed-system
目录
1.为什么要进行系统拆分?如何进行系统拆分?拆分后不用 dubbo 可以吗?
15.如何基于 dubbo 进行服务治理、服务降级、失败重试以及超时重试?
1.为什么要进行系统拆分?如何进行系统拆分?拆分后不用 dubbo 可以吗?
要是不拆分,一个大系统几十万行代码,20 个人维护一份代码,简直是悲剧啊。
1)代码经常改着改着就冲突了,各种代码冲突和合并要处理,非常耗费时间;
2)经常我改动了我的代码,你调用了我的,导致你的代码也得重新测试,麻烦的要死;
3)每次发布都是几十万行代码的系统一起发布,大家得一起提心吊胆准备上线,可能每次上线都要做很多的检查,很多异常问题的处理,简直是又麻烦又痛苦;
4)如果我现在打算把技术升级到最新的 spring 版本,还不行,因为这可能导致你的代码报错,我不敢随意乱改技术。
拆分了以后,整个世界清爽了,几十万行代码的系统,拆分成 20 个服务,平均每个服务就 1~2 万行代码,每个服务部署到单独的机器上。20 个工程,20 个 git 代码仓库,20 个开发人员,每个人维护自己的那个服务就可以了,是自己独立的代码,跟别人没关系。再也没有代码冲突了,爽。每次就测试我自己的代码就可以了,爽。每次就发布我自己的一个小服务就可以了,爽。技术上想怎么升级就怎么升级,保持接口不变就可以了,真爽。
所以简单来说,一句话总结,如果是那种代码量多达几十万行的中大型项目,团队里有几十个人,那么如果不拆分系统,开发效率极其低下,问题很多。但是拆分系统之后,每个人就负责自己的一小部分就好了,可以随便玩儿随便弄。分布式系统拆分之后,可以大幅度提升复杂系统大型团队的开发效率。
但是同时,也要提醒的一点是,系统拆分成分布式系统之后,大量的分布式系统面临的问题也是接踵而来,所以后面的问题都是在围绕分布式系统带来的复杂技术挑战在说。
2.如何进行系统拆分?
系统拆分为分布式系统,拆成多个服务,拆成微服务的架构,是需要拆很多轮的。并不是说上来一个架构师一次就给拆好了,而以后都不用拆。
第一轮;团队继续扩大,拆好的某个服务,刚开始是 1 个人维护 1 万行代码,后来业务系统越来越复杂,这个服务是 10 万行代码,5 个人;第二轮,1个服务 -> 5个服务,每个服务 2 万行代码,每人负责一个服务。
如果是多人维护一个服务,最理想的情况下,几十个人,1 个人负责 1 个或 2~3 个服务;某个服务工作量变大了,代码量越来越多,某个同学,负责一个服务,代码量变成了 10 万行了,他自己不堪重负,他现在一个人拆开,5 个服务,1 个人顶着,负责 5 个人,接着招人,2 个人,给那个同学带着,3 个人负责 5 个服务,其中 2 个人每个人负责 2 个服务,1 个人负责 1 个服务。
个人建议,一个服务的代码不要太多,1 万行左右,两三万撑死了吧。
大部分的系统,是要进行多轮拆分的,第一次拆分,可能就是将以前的多个模块该拆分开来了,比如说将电商系统拆分成订单系统、商品系统、采购系统、仓储系统、用户系统,等等吧。
但是后面可能每个系统又变得越来越复杂了,比如说采购系统里面又分成了供应商管理系统、采购单管理系统,订单系统又拆分成了购物车系统、价格系统、订单管理系统。
扯深了实在很深,所以这里先给大家举个例子,你自己感受一下,核心意思就是根据情况,先拆分一轮,后面如果系统更复杂了,可以继续分拆。你根据自己负责系统的例子,来考虑一下就好了。
3.拆分后不用 dubbo 可以吗?
当然可以了,大不了最次,就是各个系统之间,直接基于 spring mvc,就纯 http 接口互相通信呗,还能咋样。但是这个肯定是有问题的,因为 http 接口通信维护起来成本很高,你要考虑超时重试、负载均衡等等各种乱七八糟的问题,比如说你的订单系统调用商品系统,商品系统部署了 5 台机器,你怎么把请求均匀地甩给那 5 台机器?这不就是负载均衡?你要是都自己搞那是可以的,但是确实很痛苦。
所以 dubbo 说白了,是一种 rpc 框架,在本地进行的接口调用,由dubbo 代理这个调用请求,跟远程机器网络通信,给你处理掉负载均衡、服务实例上下线自动感知、超时重试等等乱七八糟的问题。那你就不用自己做了,用 dubbo 就可以了。
4.dubbo 工作原理
- 第一层:service 层,接口层,给服务提供者和消费者来实现的
- 第二层:config 层,配置层,主要是对 dubbo 进行各种配置的
- 第三层:proxy 层,服务代理层,无论是 consumer 还是 provider,dubbo 都会给你生成代理,代理之间进行网络通信
- 第四层:registry 层,服务注册层,负责服务的注册与发现
- 第五层:cluster 层,集群层,封装多个服务提供者的路由以及负载均衡,将多个实例组合成一个服务
- 第六层:monitor 层,监控层,对 rpc 接口的调用次数和调用时间进行监控
- 第七层:protocal 层,远程调用层,封装 rpc 调用
- 第八层:exchange 层,信息交换层,封装请求响应模式,同步转异步
- 第九层:transport 层,网络传输层,抽象 mina 和 netty 为统一接口
- 第十层:serialize 层,数据序列化层
5.注册中心挂了可以继续通信吗?
可以,因为刚开始初始化的时候,消费者会将提供者的地址等信息拉取到本地缓存,所以注册中心挂了可以继续通信。
6.dubbo 支持哪些通信协议
- dubbo 协议
默认就是走 dubbo 协议,单一长连接,进行的是 NIO 异步通信,基于 hessian 作为序列化协议。
使用的场景是:传输数据量小(每次请求在 100kb 以内),但是并发量很高。
为了要支持高并发场景,一般是服务提供者就几台机器,但是服务消费者有上百台,可能每天调用量达到上亿次。此时用长连接是最合适的,就是跟每个服务消费者维持一个长连接就可以,可能总共就 100 个连接。然后后面直接基于长连接 NIO 异步通信,可以支撑高并发请求。
长连接,通俗点说,就是建立连接过后可以持续发送请求,无须再建立连接。
而短连接,每次要发送请求之前,需要先重新建立一次连接。
- rmi 协议
走 Java 二进制序列化,多个短连接,适合消费者和提供者数量差不多的情况,适用于文件的传输,一般较少用。
- hessian 协议
走 hessian 序列化协议,多个短连接,适用于提供者数量比消费者数量还多的情况,适用于文件的传输,一般较少用。
- http 协议
走 json 序列化。
- webservice
走 SOAP 文本序列化。
7.dubbo 支持的序列化协议
dubbo 支持 hession、Java 二进制序列化、json、SOAP 文本序列化多种序列化协议,hessian 是其默认的序列化协议。
8.Hessian 的数据结构
Hessian 的对象序列化机制有 8 种原始类型:
- 原始二进制数据
- boolean
- 64-bit date(64 位毫秒值的日期)
- 64-bit double
- 32-bit int
- 64-bit long
- null
- UTF-8 编码的 string
另外还包括 3 种递归类型:
- list for lists and arrays
- map for maps and dictionaries
- object for objects
还有一种特殊的类型:
- ref:用来表示对共享对象的引用。
9.Protocol Buffer
是什么
Protocol Buffer
是 Google 出品的一种轻量并且高效的结构化数据存储格式,性能比 JSON
、XML
要高很多。
其实 PB 之所以性能如此好,主要得益于两个:
第一,它使用 proto 编译器,自动进行序列化和反序列化,速度非常快,应该比 XML
和 JSON
快上了 20~100
倍;
第二,它的数据压缩效果好,就是说它序列化后的数据量体积小。因为体积小,传输起来带宽和速度上会有优化。
10.dubbo 负载均衡策略
1)random loadbalance
默认情况下,dubbo 是 random loadbalance ,即随机调用实现负载均衡
可以对 provider 不同实例设置不同的权重,按照权重来负载均衡,权重越大分配流量越高。
2)roundrobin loadbalance
轮叫调度(Round Robin Scheduling)算法就是以轮叫的方式依次将请求调度不同的服务器,即每次调度执行i = (i + 1) mod n,并选出第i台服务器,即均匀地将流量打到各个机器上去。
但是如果各个机器的性能不一样,容易导致性能差的机器负载过高。所以此时需要调整权重,让性能差的机器承载权重小一些,流量少一些。
举个栗子。
跟运维同学申请机器,有的时候,我们运气好,正好公司资源比较充足,刚刚有一批热气腾腾、刚刚做好的虚拟机新鲜出炉,配置都比较高:8 核 + 16G 机器,申请到 2 台。过了一段时间,我们感觉 2 台机器有点不太够,我就去找运维同学说,“哥儿们,你能不能再给我一台机器”,但是这时只剩下一台 4 核 + 8G 的机器。我要还是得要。
这个时候,可以给两台 8 核 16G 的机器设置权重 4,给剩余 1 台 4 核 8G 的机器设置权重 2。
3)leastactive loadbalance
这个就是自动感知一下,如果某个机器性能越差,那么接收的请求越少,越不活跃,此时就会给不活跃的性能差的机器更少的请求。
4)consistanthash loadbalance
一致性 Hash 算法,相同参数的请求一定分发到一个 provider 上去,provider 挂掉的时候,会基于虚拟节点均匀分配剩余的流量,抖动不会太大。如果你需要的不是随机负载均衡,是要一类请求都到一个节点,那就走这个一致性 Hash 策略。
11.dubbo集群
Dubbo中的Cluster可以将多个服务提供方伪装成一个提供方,具体也就是将Directory中的多个Invoker伪装成一个Invoker,在伪装的过程中包含了容错的处理,负载均衡的处理和路由的处理。
12.dubbo 集群容错策略
1)failover cluster 模式(默认)
失败自动切换,自动重试其他机器,常见于读操作。(失败重试其它机器)
可以通过以下几种方式配置重试次数:<dubbo:service retries="2" />
或者<dubbo:reference retries="2" />
或者<dubbo:reference> <dubbo:method name="findFoo" retries="2" /> </dubbo:reference>
2)failfast cluster 模式
一次调用失败就立即失败,常见于非幂等性的写操作,比如新增一条记录(调用失败就立即失败)
3)failsafe cluster 模式
出现异常时忽略掉,常用于不重要的接口调用,比如记录日志。
配置示例如下:<dubbo:service cluster="failsafe" />
或者<dubbo:reference cluster="failsafe" />
4)failback cluster 模式
失败了后台自动记录请求,然后定时重发,比较适合于写消息队列这种。
5)forking cluster 模式
并行调用多个 provider,只要一个成功就立即返回。
常用于实时性要求比较高的读操作,但是会浪费更多的服务资源,可通过 forks="2"
来设置最大并行数。
6)broadcacst cluster
逐个调用所有的 provider。任何一个 provider 出错则报错(从2.1.0
版本开始支持)。
通常用于通知所有provider更新缓存或日志等本地资源信息。
13.spi 是啥?
spi,简单来说,就是 service provider interface
,说白了是什么意思呢,比如你有个接口,现在这个接口有 3 个实现类,那么在系统运行的时候对这个接口到底选择哪个实现类呢?这就需要 spi 了,需要根据指定的配置或者是默认的配置,去找到对应的实现类加载进来,然后用这个实现类的实例对象。
举个栗子。
你有一个接口 A。A1/A2/A3 分别是接口A的不同实现。你通过配置 接口 A = 实现 A2
,那么在系统实际运行的时候,会加载你的配置,用实现 A2 实例化一个对象来提供服务。
spi 机制一般用在哪儿?插件扩展的场景,比如说你开发了一个给别人使用的开源框架,如果你想让别人自己写个插件,插到你的开源框架里面,从而扩展某个功能,这个时候 spi 思想就用上了。
Java spi 思想的体现
spi 经典的思想体现,大家平时都在用,比如说 jdbc。
Java 定义了一套 jdbc 的接口,但是 Java 并没有提供 jdbc 的实现类。
但是实际上项目跑的时候,要使用 jdbc 接口的哪些实现类呢?一般来说,我们要根据自己使用的数据库,比如 mysql,你就将 mysql-jdbc-connector.jar
引入进来;oracle,你就将 oracle-jdbc-connector.jar
引入进来。
在系统跑的时候,碰到你使用 jdbc 的接口,他会在底层使用你引入的那个 jar 中提供的实现类。
dubbo 的 spi 思想
dubbo 也用了 spi 思想,不过没有用 jdk 的 spi 机制,是自己实现的一套 spi 机制。
Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class).getAdaptiveExtension();
Protocol 接口,在系统运行的时候,dubbo 会判断一下应该选用这个 Protocol 接口的哪个实现类来实例化对象来使用。
它会去找一个你配置的 Protocol,将你配置的 Protocol 实现类,加载到 jvm 中来,然后实例化对象,就用你的那个 Protocol 实现类就可以了。
上面那行代码就是 dubbo 里大量使用的,就是对很多组件,都是保留一个接口和多个实现,然后在系统运行的时候动态根据配置去找到对应的实现类。如果你没配置,那就走默认的实现好了,没问题。
@SPI("dubbo")
public interface Protocol {
int getDefaultPort();
@Adaptive
<T> Exporter<T> export(Invoker<T> invoker) throws RpcException;
@Adaptive
<T> Invoker<T> refer(Class<T> type, URL url) throws RpcException;
void destroy();
}
在 dubbo 自己的 jar 里,在/META_INF/dubbo/internal/com.alibaba.dubbo.rpc.Protocol
文件中:
dubbo=com.alibaba.dubbo.rpc.protocol.dubbo.DubboProtocol
http=com.alibaba.dubbo.rpc.protocol.http.HttpProtocol
hessian=com.alibaba.dubbo.rpc.protocol.hessian.HessianProtocol
所以说,这就看到了 dubbo 的 spi 机制默认是怎么玩儿的了,其实就是 Protocol 接口,
@SPI("dubbo")
说的是,通过 SPI 机制来提供实现类,实现类是通过 “dubbo” 作为默认 key 去配置文件里找到的,
配置文件名称与接口全限定名一样,
通过 dubbo 作为 key 可以找到默认的实现类就是 com.alibaba.dubbo.rpc.protocol.dubbo.DubboProtocol
。
如果想要动态替换掉默认的实现类,
需要使用 @Adaptive
接口,Protocol 接口中,有两个方法加了 @Adaptive
注解,就是说那俩接口会被代理实现。
比如这个 Protocol 接口搞了俩 @Adaptive
注解标注了方法,在运行的时候会针对 Protocol 生成代理类,
这个代理类的那俩方法里面会有代理代码,代理代码会在运行的时候动态根据 url 中的 protocol 来获取那个 key,
默认是 dubbo,你也可以自己指定,你如果指定了别的 key,那么就会获取别的实现类的实例了。
14.如何自己扩展 dubbo 中的组件
下面来说说怎么来自己扩展 dubbo 中的组件。
自己写个工程,要是那种可以打成 jar 包的,里面的 src/main/resources
目录下,搞一个 META-INF/services
,里面放个文件叫:com.alibaba.dubbo.rpc.Protocol
,文件里搞一个my=com.bingo.MyProtocol
。自己把 jar 弄到 nexus 私服里去。
然后自己搞一个 dubbo provider
工程,在这个工程里面依赖你自己搞的那个 jar,然后在 spring 配置文件里给个配置:
<dubbo:protocol name=”my” port=”20000” />
provider 启动的时候,就会加载到我们 jar 包里的my=com.bingo.MyProtocol
这行配置里,接着会根据你的配置使用你定义好的 MyProtocol 了,这个就是简单说明一下,你通过上述方式,可以替换掉大量的 dubbo 内部的组件,就是扔个你自己的 jar 包,然后配置一下即可。
dubbo 里面提供了大量的类似上面的扩展点,就是说,你如果要扩展一个东西,只要自己写个 jar,让你的 consumer 或者是 provider 工程,依赖你的那个 jar,在你的 jar 里指定目录下配置好接口名称对应的文件,里面通过 key=实现类
。
然后对于对应的组件,类似 <dubbo:protocol>
用你的那个 key 对应的实现类来实现某个接口,你可以自己去扩展 dubbo 的各种功能,提供你自己的实现。
15.如何基于 dubbo 进行服务治理、服务降级、失败重试以及超时重试?
服务降级,这个是涉及到复杂分布式系统中必备的一个话题,因为分布式系统互相来回调用,任何一个系统故障了,你不降级,直接就全盘崩溃?那就太坑爹了吧。
失败重试,分布式系统中网络请求如此频繁,要是因为网络问题不小心失败了一次,是不是要重试?
超时重试,跟上面一样,如果不小心网络慢一点,超时了,如何重试?
服务治理
1)调用链路自动生成
一个大型的分布式系统,或者说是用现在流行的微服务架构来说吧,分布式系统由大量的服务组成。那么这些服务之间互相是如何调用的?调用链路是啥?说实话,几乎到后面没人搞的清楚了,因为服务实在太多了,可能几百个甚至几千个服务。
那就需要基于 dubbo 做的分布式系统中,对各个服务之间的调用自动记录下来,然后自动将各个服务之间的依赖关系和调用链路生成出来,做成一张图,显示出来,大家才可以看到对吧。
2)服务访问压力以及时长统计
需要自动统计各个接口和服务之间的调用次数以及访问延时,而且要分成两个级别。
一个级别是接口粒度,就是每个服务的每个接口每天被调用多少次,TP50/TP90/TP99,三个档次的请求延时分别是多少;
第二个级别是从源头入口开始,一个完整的请求链路经过几十个服务之后,完成一次请求,每天全链路走多少次,全链路请求延时的 TP50/TP90/TP99,分别是多少。
这些东西都搞定了之后,后面才可以来看当前系统的压力主要在哪里,如何来扩容和优化啊。
3) 其它
- 服务分层(避免循环依赖)
- 调用链路失败监控和报警
- 服务鉴权
- 每个服务的可用性的监控(接口调用成功率?几个 9?99.99%,99.9%,99%)
服务降级
比如说服务 A 调用服务 B,结果服务 B 挂掉了,服务 A 重试几次调用服务 B,还是不行,那么直接降级,走一个备用的逻辑,给用户返回响应。
举个栗子,我们有接口 HelloService
。HelloServiceImpl
有该接口的具体实现。
public interface HelloService {
void sayHello();
}
public class HelloServiceImpl implements HelloService {
public void sayHello() {
System.out.println("hello world......");
}
}
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dubbo="http://code.alibabatech.com/schema/dubbo"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://code.alibabatech.com/schema/dubbo http://code.alibabatech.com/schema/dubbo/dubbo.xsd">
<dubbo:application name="dubbo-provider" />
<dubbo:registry address="zookeeper://127.0.0.1:2181" />
<dubbo:protocol name="dubbo" port="20880" />
<dubbo:service interface="com.zhss.service.HelloService" ref="helloServiceImpl" timeout="10000" />
<bean id="helloServiceImpl" class="com.zhss.service.HelloServiceImpl" />
</beans>
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://code.alibabatech.com/schema/dubbo"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://code.alibabatech.com/schema/dubbo http://code.alibabatech.com/schema/dubbo/dubbo.xsd">
<dubbo:application name="dubbo-consumer" />
<dubbo:registry address="zookeeper://127.0.0.1:2181" />
<dubbo:reference id="fooService" interface="com.test.service.FooService" timeout="10000" check="false" mock="return null">
</dubbo:reference>
</beans>
我们调用接口失败的时候,可以通过 mock
统一返回 null。
mock 的值也可以修改为 true,然后再跟接口同一个路径下实现一个 Mock 类,命名规则是 “接口名称+Mock
” 后缀。然后在 Mock 类里实现自己的降级逻辑。
public class HelloServiceMock implements HelloService {
public void sayHello() {
// 降级逻辑
}
}
失败重试和超时重试
所谓失败重试,就是 consumer 调用 provider 要是失败了,比如抛异常了,此时应该是可以重试的,或者调用超时了也可以重试。配置如下:
<dubbo:reference id="xxxx" interface="xx" check="true" async="false" retries="3" timeout="2000"/>
举个栗子。
某个服务的接口,要耗费 5s,你这边不能干等着,你这边配置了 timeout 之后,我等待 2s,还没返回,我直接就撤了,不能干等你。
可以结合你们公司具体的场景来说说你是怎么设置这些参数的:
timeout
:一般设置为 200ms
,我们认为不能超过 200ms
还没返回。
retries
:设置 retries,一般是在读请求的时候,比如你要查询个数据,你可以设置个 retries,如果第一次没读到,报错,重试指定的次数,尝试再次读取。
16.分布式服务接口的幂等性如何设计(比如不能重复扣款)?
一个分布式系统中的某个接口,该如何保证幂等性?这个事儿其实是你做分布式系统的时候必须要考虑的一个生产环境的技术问题。啥意思呢?
你看,假如你有个服务提供一些接口供外部调用,这个服务部署在了 5 台机器上,接着有个接口就是付款接口。然后人家用户在前端上操作的时候,不知道为啥,总之就是一个订单不小心发起了两次支付请求,然后这俩请求分散在了这个服务部署的不同的机器上,好了,结果一个订单扣款扣两次。
或者是订单系统调用支付系统进行支付,结果不小心因为网络超时了,然后订单系统走了前面我们看到的那个重试机制,咔嚓给你重试了一把,好,支付系统收到一个支付请求两次,而且因为负载均衡算法落在了不同的机器上,尴尬了。。。
所以你肯定得知道这事儿,否则你做出来的分布式系统恐怕容易埋坑。
这个不是技术问题,这个没有通用的一个方法,这个应该结合业务来保证幂等性。
所谓幂等性,就是说一个接口,多次发起同一个请求,你这个接口得保证结果是准确的,比如不能多扣款、不能多插入一条数据、不能将统计值多加了 1。这就是幂等性。
其实保证幂等性主要是三点:
- 对于每个请求必须有一个唯一的标识,举个栗子:订单支付请求,肯定得包含订单 id,一个订单 id 最多支付一次,对吧。
- 每次处理完请求之后,必须有一个记录标识这个请求处理过了。常见的方案是在 mysql 中记录个状态啥的,比如支付之前记录一条这个订单的支付流水。
- 每次接收请求需要进行判断,判断之前是否处理过。比如说,如果有一个订单已经支付了,就已经有了一条支付流水,那么如果重复发送这个请求,则此时先插入支付流水,orderId 已经存在了,唯一键约束生效,报错插入不进去的。然后你就不用再扣款了。
实际运作过程中,你要结合自己的业务来,比如说利用 redis,用 orderId 作为唯一键。只有成功插入这个支付流水,才可以执行实际的支付扣款。
要求是支付一个订单,必须插入一条支付流水,order_id 建一个唯一键 unique key
。你在支付一个订单之前,先插入一条支付流水,order_id 就已经进去了。你就可以写一个标识到 redis 里面去,set order_id payed
,下一次重复请求过来了,先查 redis 的 order_id 对应的 value,如果是 payed
就说明已经支付过了,你就别重复支付了。
17.分布式服务接口请求的顺序性如何保证?
其实分布式系统接口的调用顺序,也是个问题,一般来说是不用保证顺序的。但是有时候可能确实是需要严格的顺序保证。给大家举个例子,你服务 A 调用服务 B,先插入再删除。好,结果俩请求过去了,落在不同机器上,可能插入请求因为某些原因执行慢了一些,导致删除请求先执行了,此时因为没数据所以啥效果也没有;结果这个时候插入请求过来了,好,数据插入进去了,那就尴尬了。
本来应该是 “先插入 -> 再删除”,这条数据应该没了,结果现在 “先删除 -> 再插入”,数据还存在,最后你死都想不明白是怎么回事。
所以这都是分布式系统一些很常见的问题。
首先,一般来说,个人建议是,你们从业务逻辑上设计的这个系统最好是不需要这种顺序性的保证,因为一旦引入顺序性保障,比如使用分布式锁,会导致系统复杂度上升,而且会带来效率低下,热点数据压力过大等问题。
下面我给个我们用过的方案吧,简单来说,首先你得用 dubbo 的一致性 hash 负载均衡策略,将比如某一个订单 id 对应的请求都给分发到某个机器上去,接着就是在那个机器上,因为可能还是多线程并发执行的,你可能得立即将某个订单 id 对应的请求扔一个内存队列里去,强制排队,这样来确保他们的顺序性。
但是这样引发的后续问题就很多,比如说要是某个订单对应的请求特别多,造成某台机器成热点怎么办?解决这些问题又要开启后续一连串的复杂技术方案......曾经这类问题弄的我们头疼不已,所以,还是建议什么呢?
最好是比如说刚才那种,一个订单的插入和删除操作,能不能合并成一个操作,就是一个删除,或者是其它什么,避免这种问题的产生。
18.如何自己设计一个类似 Dubbo 的 RPC 框架?
说实话,就这问题,其实就跟问你如何自己设计一个 MQ 一样的道理,就考两个:
- 你有没有对某个 rpc 框架原理有非常深入的理解。
- 你能不能从整体上来思考一下,如何设计一个 rpc 框架,考考你的系统设计能力。
其实问到你这问题,你起码不能认怂,因为是知识的扫盲,那我不可能给你深入讲解什么 kafka 源码剖析,dubbo 源码剖析,何况我就算讲了,你要真的消化理解和吸收,起码个把月以后了。
所以我给大家一个建议,遇到这类问题,起码从你了解的类似框架的原理入手,自己说说参照 dubbo 的原理,你来设计一下,举个例子,dubbo 不是有那么多分层么?而且每个分层是干啥的,你大概是不是知道?那就按照这个思路大致说一下吧,起码你不能懵逼,要比那些上来就懵,啥也说不出来的人要好一些。
举个栗子,我给大家说个最简单的回答思路:
上来你的服务就得去注册中心注册吧,你是不是得有个注册中心,保留各个服务的信息,可以用 zookeeper 来做,对吧。
然后你的消费者需要去注册中心拿对应的服务信息吧,对吧,而且每个服务可能会存在于多台机器上。
接着你就该发起一次请求了,咋发起?当然是基于动态代理了,你面向接口获取到一个动态代理,这个动态代理就是接口在本地的一个代理,然后这个代理会找到服务对应的机器地址。
然后找哪个机器发送请求?那肯定得有个负载均衡算法了,比如最简单的可以随机轮询是不是。
接着找到一台机器,就可以跟它发送请求了,第一个问题咋发送?你可以说用 netty 了,nio 方式;第二个问题发送啥格式数据?你可以说用 hessian 序列化协议了,或者是别的,对吧。然后请求过去了。
服务器那边一样的,需要针对你自己的服务生成一个动态代理,监听某个网络端口了,然后代理你本地的服务代码。接收到请求的时候,就调用对应的服务代码,对吧。
这就是一个最最基本的 rpc 框架的思路。
19.集群相关源码解析
回想一下在服务消费者初始化的过程中,在引用远程服务的那一步,也就是RegistryProtocol的refer方法中,
调用了doRefer方法,doRefer方法中第一个参数就是cluster,我们就从这里开始解析。
RegistryProtocol的refer方法:
public <T> Invoker<T> refer(Class<T> type, URL url) throws RpcException {
url =
url.setProtocol
(url.getParameter(Constants.REGISTRY_KEY,Constants.DEFAULT_REGISTRY))
.removeParameter(Constants.REGISTRY_KEY);
//根据url获取注册中心实例
//这一步连接注册中心,并把消费者注册到注册中心
Registry registry = registryFactory.getRegistry(url);
//对注册中心服务的处理
if (RegistryService.class.equals(type)) {
return proxyFactory.getInvoker((T) registry, type, url);
}
//以下是我们自己定义的业务的服务处理
// group="a,b" or group="*"
Map<String, String> qs = StringUtils.parseQueryString(url.getParameterAndDecoded(Constants.REFER_KEY));
String group = qs.get(Constants.GROUP_KEY);
//服务需要合并不同实现
if (group != null && group.length() > 0 )
{
if ( ( Constants.COMMA_SPLIT_PATTERN.split( group ) ).length > 1
|| "*".equals( group ) )
{
return doRefer( getMergeableCluster(), registry, type, url );
}
}
//这里参数cluster是集群的适配类,代码在下面
return doRefer(cluster, registry, type, url);
}
接着看doRefer,真正去做服务引用的方法:
private <T> Invoker<T> doRefer
(Cluster cluster, Registry registry, Class<T> type, URL url)
{
//Directory中是Invoker的集合,相当于一个List
//也就是说这里面存放了多个Invoker,那么我们该调用哪一个呢?
//该调用哪一个Invoker的工作就是Cluster来处理的
RegistryDirectory<T> directory = new RegistryDirectory<T>(type, url);
directory.setRegistry(registry);
directory.setProtocol(protocol);
URL subscribeUrl =
new URL(Constants.CONSUMER_PROTOCOL,
NetUtils.getLocalHost(), 0, type.getName(),
directory.getUrl().getParameters());
if (! Constants.ANY_VALUE.equals(url.getServiceInterface())
&&
url.getParameter(Constants.REGISTER_KEY, true))
{
//到注册中心注册服务
registry.register
(subscribeUrl.addParameters
(Constants.CATEGORY_KEY,
Constants.CONSUMERS_CATEGORY,
Constants.CHECK_KEY,
String.valueOf(false)));
}
//订阅服务,注册中心会推送服务消息给消费者,消费者会再次进行服务的引用。
directory.subscribe
(subscribeUrl.addParameter
(Constants.CATEGORY_KEY,
Constants.PROVIDERS_CATEGORY
+ "," + Constants.CONFIGURATORS_CATEGORY
+ "," + Constants.ROUTERS_CATEGORY));
//服务的引用和变更全部由Directory异步完成
//Directory中可能存在多个Invoker
//而Cluster会把多个Invoker伪装成一个Invoker
//这一步就是做这个事情的
return cluster.join(directory);
}
集群处理的入口就是在doRefer的时候最后一步:cluster.join(directory);
首先解释下cluster,这个是根据dubbo的扩展机制生成的,
在RegistryProtocol中有一个setCluster方法,根据扩展机制可以知道,这是注入Cluster的地方,代码如下:
import com.alibaba.dubbo.common.extension.ExtensionLoader;
public class Cluster$Adpative implements com.alibaba.dubbo.rpc.cluster.Cluster {
public com.alibaba.dubbo.rpc.Invoker join
(com.alibaba.dubbo.rpc.cluster.Directory arg0) throws com.alibaba.dubbo.rpc.cluster.Directory
{
if (arg0 == null)
throw new IllegalArgumentException
("com.alibaba.dubbo.rpc.cluster.Directory argument == null");
if (arg0.getUrl() == null)
throw new IllegalArgumentException("com.alibaba.dubbo.rpc.cluster.Directory argument getUrl() == null");
com.alibaba.dubbo.common.URL url = arg0.getUrl();
String extName = url.getParameter("cluster", "failover");
if(extName == null)
throw new IllegalStateException("Fail to get extension(com.alibaba.dubbo.rpc.cluster.Cluster)
name from url(" + url.toString() + ") use keys([cluster])");
com.alibaba.dubbo.rpc.cluster.Cluster extension = (com.alibaba.dubbo.rpc.cluster.Cluster)
ExtensionLoader.getExtensionLoader
(com.alibaba.dubbo.rpc.cluster.Cluster.class).getExtension(extName);
return extension.join(arg0);
}
}
可以看到,如果我们没有配置集群策略的话,默认是用failover模式,在Cluster接口的注解上@SPI(FailoverCluster.NAME)
也可以看到默认是failover。
继续执行cluster.join方法,会首先进入MockClusterWrapper的join方法:
ublic <T> Invoker<T> join(Directory<T> directory) throws RpcException {
//先执行FailoverCluster的join方法处理
//然后将Directory和返回的Invoker封装成一个MockCluster
return new MockClusterInvoker<T>(directory,
this.cluster.join(directory));
}
看下Failover的join方法:
public <T> Invoker<T> join(Directory<T> directory) throws RpcException {
//直接返回一个FailoverClusterInvoker的实例
return new FailoverClusterInvoker<T>(directory);
}
到这里就算把Invoker都封装好了,返回的Invoker是一个MockClusterInvoker,MockClusterInvoker内部包含一个Directory和一个FailoverClusterInvoker。
Invoker都封装好了之后,就是创建代理,然后使用代理调用我们的要调用的方法。
调用方法时集群的处理
在进行具体方法调用的时候,代理中会invoker.invoke()
,
这里Invoker就是我们上面封装好的MockClusterInvoker,所以首先进入MockClusterInvoker的invoke方法:
public Result invoke(Invocation invocation) throws RpcException {
Result result = null;
//我们没配置mock,所以这里为false
//Mock通常用于服务降级
String value = directory.getUrl().getMethodParameter
(invocation.getMethodName(),
Constants.MOCK_KEY,
Boolean.FALSE.toString()).trim();
//没有使用mock
if (value.length() == 0 || value.equalsIgnoreCase("false"))
{
//这里的invoker是FailoverClusterInvoker
result = this.invoker.invoke(invocation);
}
else if (value.startsWith("force")) {
//mock=force:return+null
//表示消费方对方法的调用都直接返回null,不发起远程调用
//可用于屏蔽不重要服务不可用的时候,对调用方的影响
//force:direct mock
result = doMockInvoke(invocation, null);
}
else {
//mock=fail:return+null
//表示消费方对该服务的方法调用失败后,再返回null,不抛异常
//可用于对不重要服务不稳定的时候,忽略对调用方的影响
//fail-mock
try {
result = this.invoker.invoke(invocation);
}catch (RpcException e) {
if (e.isBiz()) {
throw e;
} else {
result = doMockInvoke(invocation, e);
}
}
}
return result;
}
我们这里因为没有配置mock属性。首先进入的是AbstractClusterInvoker的invoke方法:
public Result invoke(final Invocation invocation) throws RpcException {
//检查是否已经被销毁
checkWheatherDestoried();
//可以看到这里该处理负载均衡的问题了
LoadBalance loadbalance;
//根据invocation中的信息从Directory中获取Invoker列表
//这一步中会进行路由的处理
List<Invoker<T>> invokers = list(invocation);
if (invokers != null && invokers.size() > 0)
{
//使用扩展机制,加载LoadBalance的实现类,默认使用的是random
//我们这里得到的就是RandomLoadBalance
loadbalance = ExtensionLoader.
getExtensionLoader(LoadBalance.class).
getExtension(invokers.get(0).getUrl()
.getMethodParameter(invocation.getMethodName(),Constants.LOADBALANCE_KEY,
Constants.DEFAULT_LOADBALANCE));
}
else {
loadbalance =
ExtensionLoader.getExtensionLoader(LoadBalance.class).getExtension(Constants.DEFAULT_LOADBALANCE);
}
//异步操作默认添加invocation id
RpcUtils.attachInvocationIdIfAsync(getUrl(), invocation);
//调用具体的实现类的doInvoke方法,这里是FailoverClusterInvoker
return doInvoke(invocation, invokers, loadbalance);
}
看下FailoverClusterInvoker的invoke方法:
public Result doInvoke
(Invocation invocation, final List<Invoker<T>> invokers, LoadBalance loadbalance)
throws RpcException
{
//Invoker列表
List<Invoker<T>> copyinvokers = invokers;
//确认下Invoker列表不为空
checkInvokers(copyinvokers, invocation);
//重试次数
int len = getUrl().getMethodParameter(invocation.getMethodName(),
Constants.RETRIES_KEY, Constants.DEFAULT_RETRIES) + 1;
if (len <= 0) {
len = 1;
}
// retry loop.
RpcException le = null; // last exception.
List<Invoker<T>> invoked = new ArrayList<Invoker<T>>(copyinvokers.size()); //
invoked invokers.Set<String> providers = new HashSet<String>(len);
for (int i = 0; i < len; i++) {
//重试时,进行重新选择,避免重试时invoker列表已发生变化.
//注意:如果列表发生了变化,那么invoked判断会失效,因为invoker示例已经改变
if (i > 0) {
checkWheatherDestoried();
copyinvokers = list(invocation);
//重新检查一下
checkInvokers(copyinvokers, invocation);
}
//使用loadBalance选择一个Invoker返回
Invoker<T> invoker = select(loadbalance, invocation, copyinvokers, invoked);
invoked.add(invoker);
RpcContext.getContext().setInvokers((List)invoked);
try {
//使用选择的结果Invoker进行调用,返回结果
Result result = invoker.invoke(invocation);
return result;
}
catch (RpcException e) {}
finally {
providers.add(invoker.getUrl().getAddress());
}
}
throw new RpcException();
}
先看下使用loadbalance选择invoker的select方法:
protected Invoker<T> select
(LoadBalance loadbalance, Invocation invocation, List<Invoker<T>> invokers, List<Invoker<T>> selected)
throws RpcException
{
if (invokers == null || invokers.size() == 0)
return null;
String methodName = invocation == null ? "" : invocation.getMethodName();
//sticky,滞连接用于有状态服务,尽可能让客户端总是向同一提供者发起调用,
//除非该提供者挂了,再连另一台。
boolean sticky = invokers.get(0).getUrl().getMethodParameter
(methodName,Constants.CLUSTER_STICKY_KEY, Constants.DEFAULT_CLUSTER_STICKY) ;
{
//ignore overloaded method
if ( stickyInvoker != null && !invokers.contains(stickyInvoker))
{
stickyInvoker = null;
}
//ignore cucurrent problem
if (sticky && stickyInvoker != null && (selected == null ||
!selected.contains(stickyInvoker)))
{
if (availablecheck && stickyInvoker.isAvailable()){
return stickyInvoker;
}
}
}
Invoker<T> invoker = doselect(loadbalance, invocation, invokers, selected);
if (sticky)
{
stickyInvoker = invoker;
}
return invoker;
}
doselect方法:
private Invoker<T> doselect
(LoadBalance loadbalance,
Invocation invocation,
List<Invoker<T>> invokers,
List<Invoker<T>> selected)
throws RpcException {
if (invokers == null || invokers.size() == 0)
return null;
//只有一个invoker,直接返回,不需要处理
if (invokers.size() == 1)
return invokers.get(0);
// 如果只有两个invoker,退化成轮循
if (invokers.size() == 2 && selected != null && selected.size() > 0)
{
return selected.get(0) == invokers.get(0) ? invokers.get(1) :invokers.get(0);
}
//使用loadBalance进行选择
Invoker<T> invoker = loadbalance.select(invokers, getUrl(), invocation);
//如果 selected中包含(优先判断) 或者 不可用&&availablecheck=true 则重试.
if( (selected != null && selected.contains(invoker))
||(!invoker.isAvailable() && getUrl()!=null && availablecheck)){
try{
//重新选择
Invoker<T> rinvoker = reselect(loadbalance, invocation, invokers, selected, availablecheck);
if(rinvoker != null){
invoker = rinvoker;
}else{
//看下第一次选的位置,如果不是最后,选+1位置.
int index = invokers.indexOf(invoker);
try{
//最后在避免碰撞
invoker = index <invokers.size()-1?invokers.get(index+1) :invoker;
}catch (Exception e) {。。。 }
}
}catch (Throwable t){。。。}
}
return invoker;
}
接着看使用loadBalance进行选择,首先进入AbstractLoadBalance的select方法:
public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) {
if (invokers == null || invokers.size() == 0)
return null;
if (invokers.size() == 1)
return invokers.get(0);
// 进行选择,具体的子类实现,我们这里是RandomLoadBalance
return doSelect(invokers, url, invocation);
}
接着去RandomLoadBalance中查看:
protected <T> Invoker<T> doSelect
(List<Invoker<T>> invokers, URL url, Invocation invocation)
{
int length = invokers.size(); // 总个数
int totalWeight = 0; // 总权重
boolean sameWeight = true; // 权重是否都一样
for (int i = 0; i < length; i++) {
int weight = getWeight(invokers.get(i), invocation);
totalWeight += weight; // 累计总权重
if (sameWeight && i > 0
&& weight != getWeight(invokers.get(i - 1), invocation)) {
sameWeight = false; // 计算所有权重是否一样
}
}
if (totalWeight > 0 && ! sameWeight) {
// 如果权重不相同且权重大于0则按总权重数随机
int offset = random.nextInt(totalWeight);
// 并确定随机值落在哪个片断上
for (int i = 0; i < length; i++) {
offset -= getWeight(invokers.get(i), invocation);
if (offset < 0) {
return invokers.get(i);
}
}
}
// 如果权重相同或权重为0则均等随机
return invokers.get(random.nextInt(length));
}
上面根据权重之类的来进行选择一个Invoker返回。接下来reselect的方法不在说明,是先从非selected的列表中选择,没有在从selected列表中选择。选择好了Invoker之后,就回去FailoverClusterInvoker的doInvoke方法,接着就是根据选中的Invoker调用invoke方法进行返回结果,接着就是到具体的Invoker进行调用的过程了。这部分的解析在消费者和提供者请求响应过程已经解析过了,不再重复。
路由相关
回到AbstractClusterInvoker的invoke方法中,这里有一步是List<Invoker<T>> invokers = list(invocation);
获取Invoker列表,这里同时也进行了路由的操作,看下list方法:
protected List<Invoker<T>> list(Invocation invocation) throws RpcException {
List<Invoker<T>> invokers = directory.list(invocation);
return invokers;
}
接着看AbstractDirectory的list方法:
public List<Invoker<T>> list(Invocation invocation) throws RpcException {
if (destroyed)
{
throw new RpcException("Directory already destroyed .url: "+ getUrl());
}
//RegistryDirectory中的doList实现
List<Invoker<T>> invokers = doList(invocation);
List<Router> localRouters = this.routers; // local reference
if (localRouters != null && localRouters.size() > 0) {
for (Router router: localRouters){
try {
if (router.getUrl() == null || router.getUrl().getParameter(Constants.RUNTIME_KEY, true)) {
//路由选择
//MockInvokersSelector中
invokers = router.route(invokers, getConsumerUrl(), invocation);
}
} catch (Throwable t) {。。。}
}
}
return invokers;
}
路由来过滤之后,进行负载均衡的处理。
20.zookeeper 的使用场景
大致来说,zookeeper 的使用场景如下,我就举几个简单的,大家能说几个就好了:
- 分布式协调
- 分布式锁
- 元数据/配置信息管理
- HA高可用性
分布式协调
这个其实是 zookeeper 很经典的一个用法,简单来说,就好比,你 A 系统发送个请求到 mq,然后 B 系统消息消费之后处理了。那 A 系统如何知道 B 系统的处理结果?用 zookeeper 就可以实现分布式系统之间的协调工作。A 系统发送请求之后可以在 zookeeper 上对某个节点的值注册个监听器,一旦 B 系统处理完了就修改 zookeeper 那个节点的值,A 系统立马就可以收到通知,完美解决。
分布式锁
举个栗子。对某一个数据连续发出两个修改操作,两台机器同时收到了请求,但是只能一台机器先执行完另外一个机器再执行。那么此时就可以使用 zookeeper 分布式锁,一个机器接收到了请求之后先获取 zookeeper 上的一把分布式锁,就是可以去创建一个 znode,接着执行操作;然后另外一个机器也尝试去创建那个 znode,结果发现自己创建不了,因为被别人创建了,那只能等着,等第一个机器执行完了自己再执行。
元数据/配置信息管理
zookeeper 可以用作很多系统的配置信息的管理,比如 kafka、storm 等等很多分布式系统都会选用 zookeeper 来做一些元数据、配置信息的管理,包括 dubbo 注册中心不也支持 zookeeper 么?
HA高可用性
这个应该是很常见的,比如 hadoop、hdfs、yarn 等很多大数据系统,都选择基于 zookeeper 来开发 HA 高可用机制,就是一个重要进程一般会做主备两个,主进程挂了立马通过 zookeeper 感知到切换到备用进程。
21.一般实现分布式锁都有哪些方式?
在不同进程需要互斥地访问共享资源时,分布式锁是一种非常有用的技术手段。 有很多三方库和文章描述如何用Redis实现一个分布式锁管理器,但是这些库实现的方式差别很大,而且很多简单的实现其实只需采用稍微增加一点复杂的设计就可以获得更好的可靠性。 这篇文章的目的就是尝试提出一种官方权威的用Redis实现分布式锁管理器的算法,我们把这个算法称为RedLock。
安全和可靠性保证
在描述我们的设计之前,我们想先提出三个属性,这三个属性在我们看来,是实现高效分布式锁的基础。
- 一致性:互斥,不管任何时候,只有一个客户端能持有同一个锁。
- 分区可容忍性:不会死锁,最终一定会得到锁,就算一个持有锁的客户端宕掉或者发生网络分区。
- 可用性:只要大多数Redis节点正常工作,客户端应该都能获取和释放锁。
为什么基于故障切换的方案不够好
为了理解我们想要提高的到底是什么,我们先看下当前大多数基于Redis的分布式锁三方库的现状。
用Redis来实现分布式锁最简单的方式就是在实例里创建一个键值,创建出来的键值一般都是有一个超时时间的(这个是Redis自带的超时特性),所以每个锁最终都会释放。而当一个客户端想要释放锁时,它只需要删除这个键值即可。
表面来看,这个方法似乎很管用,但是这里存在一个问题:在我们的系统架构里存在一个单点故障,如果Redis的master节点宕机了怎么办呢?
有人可能会说:加一个slave节点!在master宕机时用slave就行了!但是其实这个方案明显是不可行的,因为这种方案无法保证第1个安全互斥属性,因为Redis的复制是异步的。
总的来说,这个方案里有一个明显的竞争条件(race condition),举例来说:
1)客户端A在master节点拿到了锁。
2)master节点在把A创建的key写入slave之前宕机了。
3)slave变成了master节点
4)B也得到了和A还持有的相同的锁(因为原来的slave里还没有A持有锁的信息)
当然,在某些特殊场景下,前面提到的这个方案则完全没有问题,比如在宕机期间,多个客户端允许同时都持有锁,如果你可以容忍这个问题的话,那用这个基于复制的方案就完全没有问题,否则的话我们还是建议你采用这篇文章里接下来要描述的方案。
采用单实例的正确实现
在讲述如何用其他方案突破单实例方案的限制之前,让我们先看下是否有什么办法可以修复这个简单场景的问题,因为这个方案其实如果可以忍受竞争条件的话是有望可行的,而且单实例来实现分布式锁是我们后面要讲的算法的基础。
要获得锁,要用下面这个命令: SET resource_name my_random_value NX PX 30000
这个命令的作用是在只有这个key不存在的时候才会设置这个key的值(NX选项的作用),超时时间设为30000毫秒(PX选项的作用) 这个key的值设为“my_random_value”。
这个值必须在所有获取锁请求的客户端里保持唯一。 基本上这个随机值就是用来保证能安全地释放锁,我们可以用下面这个Lua脚本来告诉Redis:删除这个key当且仅当这个key存在而且值是我期望的那个值。
|
这个很重要,因为这可以避免误删其他客户端得到的锁,举个例子,一个客户端拿到了锁,被某个操作阻塞了很长时间,过了超时时间后自动释放了这个锁,然后这个客户端之后又尝试删除这个其实已经被其他客户端拿到的锁。所以单纯的用DEL指令有可能造成一个客户端删除了其他客户端的锁,用上面这个脚本可以保证每个客户单都用一个随机字符串’签名’了,这样每个锁就只能被获得锁的客户端删除了。
这个随机字符串应该用什么生成呢?我假设这是从/dev/urandom生成的20字节大小的字符串,但是其实你可以有效率更高的方案来保证这个字符串足够唯一。比如你可以用RC4加密算法来从/dev/urandom生成一个伪随机流。还有更简单的方案,比如用毫秒的unix时间戳加上客户端id,这个也许不够安全,但是也许在大多数环境下已经够用了。
key值的超时时间,也叫做”锁有效时间”。这个是锁的自动释放时间,也是一个客户端在其他客户端能抢占锁之前可以执行任务的时间,这个时间从获取锁的时间点开始计算。
所以现在我们有很好的获取和释放锁的方式,在一个非分布式的、单点的、保证永不宕机的环境下这个方式没有任何问题,接下来我们看看无法保证这些条件的分布式环境下我们该怎么做。
Redlock算法
在分布式版本的算法里我们假设我们有N个Redis master节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。
我们已经描述了如何在单节点环境下安全地获取和释放锁。因此我们理所当然地应当用这个方法在每个单节点里来获取和释放锁。
在我们的例子里面我们把N设成5,这个数字是一个相对比较合理的数值,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:
1)获取当前时间(单位是毫秒)。
2)轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和锁释放时间相比小的多的超时时间:
比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了(即请求锁失败了),我们应该尽快尝试下一个master节点。
3)客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(n/2+1个,在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
4)如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
5)如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。
这个算法是否是异步的?
这个算法是基于一个假设:虽然不存在可以跨进程的同步时钟,但是不同进程时间都是以差不多相同的速度前进,这个假设不一定完全准确,但是和自动释放锁的时间长度相比不同进程时间前进速度差异基本是可以忽略不计的。
失败的重试
当一个客户端获取锁失败时,这个客户端应该在一个随机延时后进行重试,之所以采用随机延时是为了避免不同客户端同时重试导致谁都无法拿到锁的情况出现。
同样的道理客户端越快尝试在大多数Redis节点获取锁,出现多个客户端同时竞争锁和重试的时间窗口越小,可能性就越低,所以最完美的情况下,客户端应该用多路传输的方式同时向所有Redis节点发送SET命令。
这里非常有必要强调一下客户端如果没有在多数节点获取到锁,一定要尽快在获取锁成功的节点上释放锁,这样就没必要等到key超时后才能重新获取这个锁(但是如果网络分区的情况发生而且客户端无法连接到Redis节点时,会损失等待key超时这段时间的系统可用性)
释放锁
释放锁比较简单,因为只需要在所有节点都释放锁就行,不管之前有没有在该节点获取锁成功。
安全性的论证
这个算法到底是不是安全的呢?我们可以观察不同场景下的情况来理解这个算法为什么是安全的。
开始之前,让我们假设客户端可以在大多数节点都获取到锁,这样所有的节点都会包含一个有相同存活时间的key。但是需要注意的是,这个key是在不同时间点设置的,所以这些key也会在不同的时间超时,但是我们假设最坏情况下第一个key是在T1时间设置的(客户端连接到第一个服务器时的时间),最后一个key是在T2时间设置的(客户端收到最后一个服务器返回结果的时间),从T2时间开始,我们可以确认最早超时的key至少也会存在的时间为MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT,TTL是锁超时时间、(T2-T1)是最晚获取到的锁的耗时,CLOCK_DRIFT是不同进程间时钟差异,这个是用来补偿前面的(T2-T1)。其他的key都会在这个时间点之后才会超时,所以我们可以确定这些key在这个时间点之前至少都是同时存在的。
在大多数节点的key都set了的时间段内,其他客户端无法抢占这个锁,因为在N/2+1个客户端的key已经存在的情况下不可能再在N/2+1个客户端上获取锁成功,所以如果一个锁获取成功了,就不可能同时重新获取这个锁成功(不然就违反了分布式锁互斥原则),然后我们也要确保多个客户端同时尝试获取锁时不会都同时成功。 如果一个客户端获取大多数节点锁的耗时接近甚至超过锁的最大有效时间时(就是我们为SET操作设置的TTL值),那么系统会认为这个锁是无效的同时会释放这些节点上的锁,所以我们仅仅需要考虑获取大多数节点锁的耗时小于有效时间的情况。在这种情况下,根据我们前面的证明,在MIN_VALIDITY时间内,没有客户端能重新获取锁成功,所以多个客户端都能同时成功获取锁的结果,只会发生在多数节点获取锁的时间都大大超过TTL时间的情况下,实际上这种情况下这些锁都会失效 。
性能论证
这个系统的性能主要基于以下三个主要特征:
1)锁自动释放的特征(超时后会自动释放),一定时间后某个锁都能被再次获取。
2)客户端通常会在不再需要锁或者任务执行完成之后主动释放锁,这样我们就不用等到超时时间后再去获取这个锁。
3)当一个客户端需要重试获取锁时,这个客户端会等待一段时间,等待的时间相对来说会比我们重新获取大多数锁的时间要长一些,这样可以降低不同客户端竞争锁资源时发生死锁的概率。
然而,我们在网络分区时要损失TTL的可用性时间,所以如果网络分区持续发生,这个不可用会一直持续。这种情况在每次一个客户端获取到了锁并在释放锁之前被网络分区了时都会出现。
基本来说,如果持续的网络分区发生的话,系统也会在持续不可用。
性能、故障恢复和fsync
很多使用Redis做锁服务器的用户在获取锁和释放锁时不止要求低延时,同时要求高吞吐量,也即单位时间内可以获取和释放的锁数量。为了达到这个要求,一定会使用多路传输来和N个服务器进行通信以降低延时(或者也可以用假多路传输,也就是把socket设置成非阻塞模式,发送所有命令,然后再去读取返回的命令,假设说客户端和不同Redis服务节点的网络往返延时相差不大的话)。
然后如果我们想让系统可以自动故障恢复的话,我们还需要考虑一下信息持久化的问题。
为了更好的描述问题,我们先假设我们Redis都是配置成非持久化的,某个客户端拿到了总共5个节点中的3个锁,这三个已经获取到锁的节点中随后重启了,这样一来我们又有3个节点可以获取锁了(重启的那个加上另外两个),这样一来其他客户端又可以获得这个锁了,这样就违反了我们之前说的锁互斥原则了。
如果我们启用AOF持久化功能,情况会好很多。举例来说,我们可以发送SHUTDOWN命令来升级一个Redis服务器然后重启之,因为Redis超时时效是语义层面实现的,所以在服务器关掉期间时超时时间还是算在内的,我们所有要求还是满足了的。然后这个是基于我们做的是一次正常的shutdown。
但是如果是断电这种意外停机呢?如果Redis是默认地配置成每秒在磁盘上执行一次fsync同步文件到磁盘操作,那就可能在一次重启后我们锁的key就丢失了。理论上如果我们想要在所有服务重启的情况下都确保锁的安全性,我们需要在持久化设置里设置成永远执行fsync操作,但是这个反过来又会造成性能远不如其他同级别的传统用来实现分布式锁的系统。
然后问题其实并不像我们第一眼看起来那么糟糕,基本上只要一个服务节点在宕机重启后不去参与现在所有仍在使用的锁,这样正在使用的锁集合在这个服务节点重启时,算法的安全性就可以维持,因为这样就可以保证正在使用的锁都被所有没重启的节点持有。
为了满足这个条件,我们只要让一个宕机重启后的实例,至少在我们使用的最大TTL时间内处于不可用状态,超过这个时间之后,所有在这期间活跃的锁都会自动释放掉。 使用延时重启的策略基本上可以在不适用任何Redis持久化特性情况下保证安全性,然后要注意这个也必然会影响到系统的可用性。举个例子,如果系统里大多数节点都宕机了,那在TTL时间内整个系统都处于全局不可用状态(全局不可用的意思就是在获取不到任何锁)。
扩展锁来使得算法更可靠
如果客户端做的工作都是由一些小的步骤组成,那么就有可能使用更小的默认锁有效时间,而且扩展这个算法来实现一个锁扩展机制。基本上,客户端如果在执行计算期间发现锁快要超时了,客户端可以给所有服务实例发送一个Lua脚本让服务端延长锁的时间,只要这个锁的key还存在而且值还等于客户端获取时的那个值。
客户端应当只有在失效时间内无法延长锁时再去重新获取锁(基本上这个和获取锁的算法是差不多的) 然而这个并不会对从本质上改变这个算法,所以最大的重新获取锁数量应该被设置成合理的大小,不然性能必然会受到影响。
zk 分布式锁
zk 分布式锁,其实可以做的比较简单,就是某个节点尝试创建临时 znode,此时创建成功了就获取了这个锁;这个时候别的客户端来创建锁会失败,只能注册个监听器监听这个锁。释放锁就是删除这个 znode,一旦释放掉就会通知客户端,然后有一个等待着的客户端就可以再次重新加锁。
/**
* ZooKeeperSession
*
* @author bingo
* @since 2018/11/29
*
*/
public class ZooKeeperSession {
private static CountDownLatch connectedSemaphore = new CountDownLatch(1);
private ZooKeeper zookeeper;
private CountDownLatch latch;
public ZooKeeperSession() {
try {
this.zookeeper = new ZooKeeper("192.168.31.187:2181,192.168.31.19:2181,192.168.31.227:2181", 50000, new ZooKeeperWatcher());
try {
connectedSemaphore.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("ZooKeeper session established......");
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取分布式锁
*
* @param productId
*/
public Boolean acquireDistributedLock(Long productId) {
String path = "/product-lock-" + productId;
try {
zookeeper.create(path, "".getBytes(), Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
return true;
} catch (Exception e) {
while (true) {
try {
// 相当于是给node注册一个监听器,去看看这个监听器是否存在
Stat stat = zk.exists(path, true);
if (stat != null) {
this.latch = new CountDownLatch(1);
this.latch.await(waitTime, TimeUnit.MILLISECONDS);
this.latch = null;
}
zookeeper.create(path, "".getBytes(), Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
return true;
} catch (Exception ee) {
continue;
}
}
}
return true;
}
/**
* 释放掉一个分布式锁
*
* @param productId
*/
public void releaseDistributedLock(Long productId) {
String path = "/product-lock-" + productId;
try {
zookeeper.delete(path, -1);
System.out.println("release the lock for product[id=" + productId + "]......");
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 建立zk session的watcher
*
* @author bingo
* @since 2018/11/29
*
*/
private class ZooKeeperWatcher implements Watcher {
public void process(WatchedEvent event) {
System.out.println("Receive watched event: " + event.getState());
if (KeeperState.SyncConnected == event.getState()) {
connectedSemaphore.countDown();
}
if (this.latch != null) {
this.latch.countDown();
}
}
}
/**
* 封装单例的静态内部类
*
* @author bingo
* @since 2018/11/29
*
*/
private static class Singleton {
private static ZooKeeperSession instance;
static {
instance = new ZooKeeperSession();
}
public static ZooKeeperSession getInstance() {
return instance;
}
}
/**
* 获取单例
*
* @return
*/
public static ZooKeeperSession getInstance() {
return Singleton.getInstance();
}
/**
* 初始化单例的便捷方法
*/
public static void init() {
getInstance();
}
}
也可以采用另一种方式,创建临时顺序节点:
如果有一把锁,被多个人给竞争,此时多个人会排队,第一个拿到锁的人会执行,然后释放锁;后面的每个人都会去监听排在自己前面的那个人创建的 node 上,一旦某个人释放了锁,排在自己后面的人就会被 zookeeper 给通知,一旦被通知了之后,就 ok 了,自己就获取到了锁,就可以执行代码了。
public class ZooKeeperDistributedLock implements Watcher {
private ZooKeeper zk;
private String locksRoot = "/locks";
private String productId;
private String waitNode;
private String lockNode;
private CountDownLatch latch;
private CountDownLatch connectedLatch = new CountDownLatch(1);
private int sessionTimeout = 30000;
public ZooKeeperDistributedLock(String productId) {
this.productId = productId;
try {
String address = "192.168.31.187:2181,192.168.31.19:2181,192.168.31.227:2181";
zk = new ZooKeeper(address, sessionTimeout, this);
connectedLatch.await();
} catch (IOException e) {
throw new LockException(e);
} catch (KeeperException e) {
throw new LockException(e);
} catch (InterruptedException e) {
throw new LockException(e);
}
}
public void process(WatchedEvent event) {
if (event.getState() == KeeperState.SyncConnected) {
connectedLatch.countDown();
return;
}
if (this.latch != null) {
this.latch.countDown();
}
}
public void acquireDistributedLock() {
try {
if (this.tryLock()) {
return;
} else {
waitForLock(waitNode, sessionTimeout);
}
} catch (KeeperException e) {
throw new LockException(e);
} catch (InterruptedException e) {
throw new LockException(e);
}
}
public boolean tryLock() {
try {
// 传入进去的locksRoot + “/” + productId
// 假设productId代表了一个商品id,比如说1
// locksRoot = locks
// /locks/10000000000,/locks/10000000001,/locks/10000000002
lockNode = zk.create(locksRoot + "/" + productId, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
// 看看刚创建的节点是不是最小的节点
// locks:10000000000,10000000001,10000000002
List<String> locks = zk.getChildren(locksRoot, false);
Collections.sort(locks);
if(lockNode.equals(locksRoot+"/"+ locks.get(0))){
//如果是最小的节点,则表示取得锁
return true;
}
//如果不是最小的节点,找到比自己小1的节点
int previousLockIndex = -1;
for(int i = 0; i < locks.size(); i++) {
if(lockNode.equals(locksRoot + “/” + locks.get(i))) {
previousLockIndex = i - 1;
break;
}
}
this.waitNode = locks.get(previousLockIndex);
} catch (KeeperException e) {
throw new LockException(e);
} catch (InterruptedException e) {
throw new LockException(e);
}
return false;
}
private boolean waitForLock(String waitNode, long waitTime) throws InterruptedException, KeeperException {
Stat stat = zk.exists(locksRoot + "/" + waitNode, true);
if (stat != null) {
this.latch = new CountDownLatch(1);
this.latch.await(waitTime, TimeUnit.MILLISECONDS);
this.latch = null;
}
return true;
}
public void unlock() {
try {
// 删除/locks/10000000000节点
// 删除/locks/10000000001节点
System.out.println("unlock " + lockNode);
zk.delete(lockNode, -1);
lockNode = null;
zk.close();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
}
}
public class LockException extends RuntimeException {
private static final long serialVersionUID = 1L;
public LockException(String e) {
super(e);
}
public LockException(Exception e) {
super(e);
}
}
}
redis 分布式锁和 zk 分布式锁的对比
- redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
- zk 分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。
另外一点就是,如果是 redis 获取锁的那个客户端 出现 bug 挂了,那么只能等待超时时间之后才能释放锁;而 zk 的话,因为创建的是临时 znode,只要客户端挂了,znode 就没了,此时就自动释放锁。
redis 分布式锁大家没发现好麻烦吗?遍历上锁,计算时间等等......zk 的分布式锁语义清晰实现简单。
所以先不分析太多的东西,就说这两点,我个人实践认为 zk 的分布式锁比 redis 的分布式锁牢靠、而且模型简单易用。