今天对分布式做一些简单的总结,说实话,学习一门技术,主要还是学习它的设计理念和思想。
注册中心
当前流行的注册中心有哪些:
- nacos:支持AP和CP(默认是AP),可通过配置自己调节,使用的Raft算法。
- zookeeper:支持CP,保证的一致性,使用的zab协议,采用中心化思想,所以必须有主节点。通过半数原则选出主节点,但弊端就是如果节点挂掉超过一半,整个集群就无法使用。
- eureka:支持AP,保证的可用性,通过去中心化思想的方式,即没有主节点,或者每个节点都算主节点,只要节点不全部挂掉,就依然可以使用。但弊端就是出现故障时,不保证每个节点数据的一致性。
不明白CAP原则可看:分布式之CAP原则详解。
想了解Raft算法可看:Raft算法原理。
注册中心主要解决的问题是什么?在注册中心未出来前,我们一般是把服务地址写在配置文件里。当我们的服务宕机了之后,恢复过来时可能服务的ip就发生了变化(或者因为其他原因改变了ip和端口),按照旧的解决办法就是在配置文件里修改路径,然后打包再发到服务器上。这种做法弊端很大,那么在之前一般都是通过数据库来解决的,比如我们新建一张表,表里去储存服务的路径,然后我们每次请求时先去数据库里拿一下服务路径。但是这样也不好,因为每次请求都会去请求一次数据库。那么在数据库的基础上加redis缓存呢?这样确实能解决问题,但是还是会有其他弊端,比如我们新加了一个服务,那么这个时候是不是就人工的去插入一条数据,没有实现真正的动态化。
注册中心的特点:
- 存放我们的接口的调用地址,即ip:端口。
- 动态感知,当地址发生变化时,或者增加了服务地址,注册中心就能感知到。
大致流程:
- 比如我们有有一个订单服务,那么在订单服务启动的时候,订单服务会在注册中心里注册自己。注册时会以key-value的形式,key表示服务名称,value表示服务地址列表,大概就是<String,List>这样。
- 为什么是服务地址列表呢,比如,单个服务无法满足日常的需求了,那么想进行扩充,原本是订单服务1,现在再增加一个订单服务2,做一个负载均衡。而它们本身都是订单服务,服务名称相同,所以存放时的value就是一个服务地址列表了。
- 当订单服务启动好了之后,如果有其他服务(消费者)调用订单服务(生产者)。那么消费者会从注册中心拿到生产者的服务地址列表,然后消费者会通过负载均衡器选择一个地址,实现RPC远程调用。
- 如果服务宕机了,注册中心是有心跳机制的,如果发现服务宕机后,会将这个脏地址给删除掉,所以不用担心会出现无效地址。
负载均衡
负载均衡算法,仅列举最常用的3个:
- 轮询:第一次请求服务1,第二次请求服务2,依次类推。实现方式也简单,比如:我们本地记录一下这个接口的请求次数,然后求模,单数就是请求的服务1,双数就服务2。记录次数时考虑一下它的原子性,所以最好使用AtomicInteger这样的保证原子性的类。
- 加权轮询(权重策略):比如服务1的权重是2,服务2的权重是1,那么三次访问中,会请求两次服务1,请求一次服务2。
- 随机(具有一定的不公平性):Java里有一个Random类可取随机数。
负载均衡器分为:
- 本地负载均衡器:例如ribbon、LoadBalancerClient(应用于dubbo,feign客户端/rpc远程调用框架),这些都是依赖于注册中心。
- 服务器负载均衡器:例如nginx(请求时先到nginx服务器,然后nginx通过负载均衡算法,将当前请求的地址给得到)。
Feign
有服务提供了一个接口
@RequestMapping(value = "/getUser/{userId}", method = RequestMethod.GET)
public @ResponseBody UserInfo userInfoByToken(@PathVariable("userId") String userId) throws Exception {
UserInfo userInfo = authService.userInfoById(userId);
return userInfo;
}
在没有使用Feign的时候,我们调用其他服务的时候大概是这样写的(例子是用springcloud alibab写的,使用的是OpenFeign):
@Autowired
private RestTemplate restTemplate;
@RequestMapping("/getUser/{userId}")
public CommonResult getUser(@PathVariable String userId) {
// 从注册中心拿到服务地址列表,此处省略代码
// 通过负载均衡算法拿到准备使用的地址,此处省略代码
// userServiceUrl就是准备使用的服务地址
return restTemplate.getForObject(userServiceUrl + "/getUser/{1}", CommonResult.class, userId);
}
在使用了Feign后:
/**
* user-service是我们的服务名称
*/
@FeignClient(value = "user-service")
public interface UserService {
/**
* 获取用户信息
*/
@GetMapping(value = "/getUser")
CommonResult getUser(@RequestParam("userId") String userId);
}
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private UserService userService;
@Override
public CommonResult getUser(String userId) {
return userService.getUser(userId);
}
}
使用了Feign后,是不是更贴近我们平时开发的习惯了,代码阅读起来更方便了。这其中的原理其实也很简单,当我们调用接口时,这里会走一个代理模式。
- @FeignClient(value = “user-service”)里是我们的服务名称,那么我们就可以通过Java反射拿到它的服务名称。
- 有了这个服务名称,我们就能在注册中心拿到对应的服务地址了。然后通过@GetMapping(value = “/getUser”),我们同理可以拿到接口的路径,那么将服务地址和接口路径拼起来就是我们需要的一个完整的路径了。
- 这个时候拿到了路径,就需要代理模式了。我们定义的UserService接口里就是我们需要代理的方法,LoginServiceImpl类就是我们的被代理类。
- 那么代理类去哪了?当然是框架帮我们都解决了,所以我们可以直接使用。
配置中心
配置中心和注册中心有些类似,同样的道理,如果配置文件修改了,我们一般都是打包重新发一次,太麻烦。既然和注册中心类似,我就不键盘耐久伏笔了。
如今常见的配置中心大概就是这几种:nacos,Spring Cloud Config,阿波罗。又一次看到了nacos,但是并不是写错了。对,nacos既可以是注册中心,又可以是配置中心,就是这么牛,阿里出品必属精品。
网关
首先要明白一件事,在使用了网关后,所有微服务接口的入口都是由网关实现转发(注意这里是转发,使用转发,地址栏不发生变化,请求域的里数据也会保留,如果是重定向就不行)的。比如我们有:商品服务、会议服务、交易服务、支付服务等等n多个服务,在未使用网关前,我们要通过token验证用户身份,最直接的做法就是在每一个服务上都设置一个过滤器,过滤掉那些没有权限的请求。但是这么做不太好,有了网关之后,相当于所有的请求都先通过网关,网关会通过请求的服务名称去注册中心拿到服务路径,并且要身份验证通过了再进行转发,转发给对应的服务。
微服务网关解决的问题:
- 解决统一微服务登录认证问题,减少代码冗余。
- 解决跨域问题
- 保护服务,因为网关可以限流、白名单、黑名单等等。
- 权限控制
- 统一日志处理,传统方式是通过aop的方式在每个服务中加日志处理,但是现在有网关了。所有请求都会经过网关,那么日志是不是在网关处理就更方便了。
在Spring Cloud Netflix中是用zuul网关,而Spring Cloud Alibaba中是用的gateway,那么他们直接的区别是什么呢?
- zuul网关,是基于Servlet实现的,阻塞式的,不支持长连接(在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输 HTTP 数据 的 TCP 连接不会关闭,客户端再次访问这个服务器时,会继续使用这一条已经建立的连接。短连接的话就是建立一次连接,任务结束就中断连接)。
- gateway网关,是基于spring5构建的,响应式非阻塞式的,支持长连接。
熔断和降级
什么是熔断
服务熔断也称服务隔离,可以把它看作“保险丝”。当下游服务出现某些状况时(访问压力过大而响应变慢或失败),上游服务为了保护系统整体的可用性可以暂时切断服务,从而防止应用程序不断地尝试执 行可能会失败的操作给系统造成“雪崩”,或者大量的超时等待导致系统卡死。这种牺牲局部,保全整体的措施就叫做熔断。
-
开启熔断:在固定时间窗口内,接口调用超时比率达到一个阈值,会开启熔断。进入熔断状态后,后续对该服务接口的调用不再经过网络,直接执行本地的默认方法,达到服务降级的效果。
-
熔断恢复:熔断不可能是永久的。当经过了规定时间之后,服务将从熔断状态恢复过来,再次接受调用方的远程调用。
什么是降级
当服务器压力剧增的情况下,根据实际业务情况及流量,对一些服务和页面有策略的不处理或换种简单的方式处理,从而释放服务器资源以保证核心交易正常运作或高效运作。
当整个微服务架构整体的负载超出了预设的上限阈值或即将到来的流量预计将会超过预设的阈值时,为了保证重要或基本的服务能正常运行,我们可以将一些 不重要 或 不紧急 的服务或任务进行服务的 延迟使用 或 暂停使用。就如天猫面对双11,如此庞大的访问量依然能扛下来,除了使用流量削峰、缓存等一些方式外,也采用了降级,暂时停用了一些无关紧要的服务。
个人理解:熔断和降级都是对服务的保护,但是熔断一般都是服务已经出现了意外状况,而做出的应急处理,暂时性的切断服务以保全整体。而降级是预感到可能会出现意外状况(如服务器访问压力骤然增大),采取释放一些不重要的服务器资源的方法,来集中服务器资源去处理一些重要的业务。所以,一般都会有一个分布式开关,用于实现服务的降级。
在Spring Cloud Netflix中一般是使用的Hystrix作为熔断中间件,而Spring Cloud Alibaba中是用的Sentinel。
- Hystrix 的关注点在于以 隔离 和 熔断 为主的容错机制。
- Sentinel除了有熔断降级的功能,还有着多样化的流量控制、系统负载保护和实时监控和控制
我个人更推崇Sentinel,因为它更香,我们可以在配置好Sentinel之后,直接在浏览器打开Sentinel的控制台进行操作,而Hystrix 的资源模型设计上采用了命令模式。但是Sentinel除了熔断降级以外,在流量控制上也非常出色,举个例子:限流,可以限制某个接口每秒的最大访问量,还支持热词限流,线程池隔离和信号量隔离也是都支持的,并且还支持多样化的流量整形策略,给我们更多选择。
分布式事务
在我们传统的单数据源(比如数据源只有一个数据库)的情况下,我们的事务遵循ACID的理念,当事务中某一个地方出错,那么整个事务直接回滚了,这样就可以保证我们的数据一致性。但是在分布式系统中,会存在多数据源,比如,我们有订单服务、仓库服务等等多个服务,每个服务都有着自己的数据源。如果用户下单,订单服务处理了之后,就把数据发送到仓库服务进行处理。但是订单服务这时报错了,它可以自己回滚,但是仅仅只是回滚的订单服务,仓库服务是不知道订单已经出错了。这就是分布式事务存在的问题,在解决分布式事务前需要先了解BASE 理论。
BASE 理论是对 CAP 理论的延伸,核心思想是即使无法做到强一致性(Strong Consistency,CAP 的一致性就是强一致性),但应用可以采用适合的方式达到最终一致性(Eventual Consitency)。
- 基本可用(Basically Available): 基本可用是指分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。电商大促时,为了应对访问量激增,部分用户可能会被引导到降级页面,服务层也可能只提供降级服务。这就是损失部分可用性的体现。
- 软状态(Soft State): 软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据至少会有三个副本,允许不同节点间副本同步的延时就是软状态的体现。MySQL Replication 的异步复制也是一种体现。
- 最终一致性(Eventual Consistency): 最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。
ACID 是传统数据库常用的设计理念,追求强一致性模型。BASE 支持的是大型分布式系统,提出通过牺牲强一致性获得高可用性。
那么如何解决分布式事务问题?
究其原因,就是订单服务出错了,但是无法通知到派送服务。那么解决的方法也很简单,就是出了问题的时候能通知一下派送服务不就可以了嘛。所以,分析到最后发现竟然就是一个网络通信问题。但是网络通信至今最大的问题就是它的不可靠性,所以订单服务出现故障怎么办?这就是BASE 理论中提到的基本可用,你要保证服务的可用性,不能一个地方出问题就全部完蛋。这个时候订单出了问题,订单服务认为此订单是异常的,而仓库服务认为此订单是正常的,针对同一订单,两个服务的订单数据却不相同,这就是中间的一个软状态。最后通知到仓库服务后,那么通过两边服务的处理后,订单数据最后达成一致,这就是最终一致性。
当然以上例子只是可能出现问题的其中一种情况,但是最终的目的都是保证服务的健康和数据的最终一致性。而解决的办法也是很多的:
- 基于可靠消息服务来实现分布式事务,比如使用rabbitmq,rocketmq。它们都是可靠的消息队列。
- 使用分布式事务中间件,例如:Seata。
分布式锁
在以前单机模式的情况下,为了保证资源在高并发情况下的同一时间只能被同一个线程使用。方式也很简单,通过synchronized关键字或者lock都行。但是由于分布式系统是多服务模式,并非单机模式,这使得原单机模式下的并发控制锁策略失效。为了解决这个问题就需要一种跨服务的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
解决方法:
- 基于数据库,这种方式又分为悲观锁和乐观锁两种,说白了就是建一张资源锁的表,该表里会存有锁定资源的相关信息,使用该资源时就会更改这条数据的状态将锁的拥有者存入,当前服务如果是锁的拥有者才有资格使用此资源。
- 基于zookeeper,没用过 T_T。
- 基于redis,要知道redis里有一个命令是SETNX,这里类似,中心思想还是在于一个锁字,谁拿到锁,谁就有资格使用资源,个人建议是用这种方式。