1. 收集内容总结
本篇对以下内容进行了汇总与收集:
- 微服务的总体设计架构
- 各架构下的具体实现技术,以及可供参考的技术实现思路
我们首先可以通过如下所示的整体流程图来梳理清楚微服务所涉及到的关键技术,这可以为我们自主设计微服务系统提供思路上的帮助,这些关键技术分别是:
- 注册中心与配置中心:这是微服务的头部,负责整个微服务集群的调度与管理
- 网关:负责整个微服务的安全访问与流控
- 服务集群:通过多点部署与分布式实现,来高效的为外界提供服务
- 分布式缓存/分布式搜索/分布式数据库:提供高可用,高效率的数据支持
- 消息队列:为不同的微服务间提供通信支持
- 分布式日志服务与系统监控链路追踪
微服务的关键字:高可用,分布式,可应对大规模负载
微服务的思路与技术栈总结
2. 微服务的头部:服务信息的管理
2.1 eureka注册中心
我们设想,部署在两台服务器上的不同业务,想要互相进行业务的访问可以怎么办?正如访问页面的跳转一般,在Java后台,我们也可以通过RestTemplete
发起HTTP请求,并接收返回包。RestTemplete是智能的,你仍然可以通过传入类的class属性,来完成由SpringBoot框架返回的Json数据的自动赋值。
然而,虽然SpringBoot框架提供了RestTemplete
这样的类来辅助进行多台服务器间的独立部署,但这种侵入式的编程实在是不雅观。比如一台服务器的项目发生迁移,地址改变,另一台服务器也必须重新部署。
因此,一个合理的思路就是:
建立一个专门的注册中心,注册中心的任务是:
- 为服务器的地址信息以及专用名建立字段,服务器间通过专用名向注册中心请求地址并相互访问
- 维护服务器存活信息,eureka要求服务器每隔30s进行心跳续约,保证注册中心中的服务器都是可访问的
这就是eureka完成注册管理工作的原理
2.2 nacos注册中心
nacos集成了eureca的思想和操作方式,由于其服务器启动于命令行,所以不需要在项目里添加服务器模块,只需要添加客户端依赖即可
nacos相比于eureca的优势主要体现在它对服务器的主动性:(1)对于消费者增加了主动推送变更信息的功能,当有服务器done机时,消费者可以更快知道;(2)对于生产者增加临时实例和非临时实例的区别,临时实例采用被动的心跳检测,并且在done机后剔除服务器,非临时实例采用主动的问询,在done机后不会主动剔除,而是等待服务器恢复正常
nacos的工作流程如下图所示(比eureka多了push线)
2.3 ribbon负载均衡
由于微服务架构中,服务器由注册中心进行域名的管理,所以在进行负载均衡时,服务消费者需要和注册中心进行拉取才能调用负载均衡算法。因此,有关于ribbon负载均衡的内容也记录在Section 2中
负载均衡有很多相关算法,如常见的随机选择,轮循选择,或者是基于地域与权重的优先度选择等等
ribbon中集成了很多相关的负载均衡算法,通过IRule属性进行控制(因此,一种修改默认负载均衡算法的方法就是直接填充一个返回值类型为IRule的Bean),有关于ribbon集成的负载均衡算法可以参看下面的博客:Ribbon七种负载均衡算法
2.4 nacos配置中心
nacos还实现了一个配置管理中心,服务器可以把需要进行热更新的配置信息放在nacos里,当该配置发生变化的时候,nacos会主动通告与该配置关联的服务器,如果服务器端读取时设置了热更新,那么配置信息就可以直接应用于服务器
2.5 nacos的集群
nacos的集群,或者说集群这个概念本身就很有意思,今天借着Section 2.5,我们就来浅析一下集群和单点所不同的工作。
首先,我们根据下图所示建立了一个3节点组成的nacos集群
要理解集群,首先必须认识到等价节点的信息一致性,与单点工作不同,单点是独立存储运行信息,而在集群中,等价节点能接收到的信息是一致的。如上图所示,三个nacos节点连在同一个数据库集群且访问无限制区别,所以这三个节点是等价的。
信息一致性的好处就是,nginx做负载均衡的时候,只需要根据负载均衡规则选择执行任务的节点,而不需要担心它是否能执行这个任务。
我们结合Section 2.2中nacos注册中心的业务来进一步说明集群的工作:
- nacos client处理Section 2.2中对于所有关联服务器的pull以及push任务(也可能存在对服务器返回数据的存储任务等等),生成任务序列
- 任务序列发放至nginx,由nginx根据负载均衡向所有节点分配任务
- 节点接收任务指令并执行任务,向服务器发送信息
总结:
- nacos client就像检测口,接收请求(其实本质就是nginx部署时的服务器,这里为了便于理解而分开阐述)
- nginx就像传送带,分发进入的“包裹”
- 各个等价节点就像处理机器,处理包裹并传出
2.6 服务器间的HTTP请求
服务消费者在获取到生产者地址后,还需要通过客户端来发送HTTP请求,以完成消费任务
基础的方式如Section 2.1中所介绍的,使用RestTemplete类来发送HTTP请求。但由于其在编写复杂请求的URL路径上的困难,我们更倾向于使用与SpringMVC有相同逻辑的feign来实现客户端请求
为了实现代码的复用,消费被抽取为一个独立的feign-api来管理,消费者可调用该jar包来选择对应的消费功能;同时,为了减少每一次请求都重新建立TCP的三次握手和四次挥手,我们采用连接池来优化feign的性能
上述功能在具体实现上,体现为如下步骤:
- 独立的feign-api创建
- 根据SpringMVC controller层代码规范,书写客户端访问代码,注意为类添加
@FeignClient(value = [service_name])
的注解 - 可以选择建立config文件夹,编写config控制类,通过Bean将返回的config对象注入Spring容器,为消费者提供默认的配置信息
- 根据SpringMVC controller层代码规范,书写客户端访问代码,注意为类添加
- 消费者调用feign-api,生成FeignClient的Bean实例
- 在SpringBoot启动类上添加
@EnableFeignClients(clients = {[client_name].class}, defaultConfiguration = [config_name].class)
注解,生成符合[config_name]
的[client_name]
的Bean实例 - 在application.yml配置启用连接池
- 自动装配
[client_name]
的Bean实例并进行消费
- 在SpringBoot启动类上添加
3. 微服务的门户:Gatway
3.1 路由
gatway设置了10多种路由工厂,可以设置不同的条件,只有满足筛选条件的才由Gateway路由到对应的服务器地址,路由工厂的详细说明可以参看官方中文文档:gateway路由断言工厂
由于需要路由到服务器,所以gateway模块也需要在nacos中进行注册
3.2 过滤
过滤分为默认过滤器(default-filters),相关路由过滤器(filters),全局过滤器(GlobalFilter),它们都是加载在路由之后的执行器,如下图所示:
其中,默认过滤器和相关路由过滤器只是影响的路由范围不同,但底层实现都是各种各样的GatewayFilter工厂所创建的GatewayFilter实例。工厂的详细描述可以参见:GatewayFilter工厂。
过滤器间首先以优先级做排序,其中默认过滤器和相关路由过滤器都是在各自的集合内以1为优先级顺延的;在优先级相同的情况下,再按照默认过滤器->相关路由过滤器->全局过滤器排列。
3.3 跨域
跨域指的是浏览器在访问某个URL中的ajax请求时,发现请求的URL路径与当前正在访问的URL要么域名不同,要么端口不同。这种情况下浏览器会拒绝本次ajax请求,这就是跨域问题
要解决跨域问题,只需要浏览器在访问某个URL时,由对应的服务器告知浏览器该URL访问可以跨域即可,这就是CORS
在Gateway中,通过配置形式实现了CORS,配置详情可参照:Gateway CORS
4. 微服务的容器:Docker
Docker个人觉得是微服务技术栈里很有创新的一个功能了,它针对各种服务的依赖不同,编译测试环境不同而导致的部署困难,无法跨环境(例如当服务的依赖编译自Ubuntu,就无法迁移到Centos,因为调用函数不同),提出了很巧妙的解决方案:
- 将一个个的服务封装为镜像,镜像包含服务所需的所有依赖,这里的依赖不仅指第三方工具,还指工具所依赖的系统函数。
- 通过容器启动镜像,可以不依赖于当前系统的函数,而是直接通过封装的系统函数去调用内核。即不论编译成镜像时的环境如何,启动该镜像的容器都是可以直接运行在任何Linux系统的
Docker的解决方案中有两个重要的内容,镜像以及启动镜像的容器,下面我们将一一进行介绍
4.1 镜像
镜像在docker中就是服务封装的别称,一个镜像代表着一个部署后可独立自主运行的服务。而镜像部分最关键的知识就是镜像的生成。只有了解镜像是如何生成的,我们才能把我们自己的微服务给部署到docker中
镜像就是用来告诉docker要怎么来启动打包的服务:
- 基础镜像层:告知docker需要依赖的操作系统,方便docker打包相关的系统函数库
- 顺序构建层:按照依赖的先后顺序,分层告知docker应该按照什么顺序去导入(或构建)包(以及依赖、配置)
- 入口层:镜像运行的入口,包括启动脚本和访问端口
4.2 容器
容器是启动镜像的平台,内部是一个简化版的linux系统(即只包含了镜像运行所需的函数库与文件夹)
一般来说,通过docker run命令启动的容器默认位于子网bridge,默认启动的各容器可以直接通过名称进行相互之间的访问(不用向外暴露端口)
还可以通过docker-compose批量启动容器,docker-compose会自动创建虚拟子网并把批启动的容器放在同一子网管辖,因此它们也可以通过名称互相访问
容器的子网是可以通过docker network命令改变的,也可以在docker-compose启动时选择子网,从而保证启动方式不同的各个容器处于同一子网下,这方面可移步我的另一篇博客:通过docker-compose完成负载均衡的nacos集群,里面有更细的原理和实验展示
除了同一服务器上部署容器时的名称访问需求外,对于在不同服务器上进行的集群部署,我们也希望它们之间可以实现名称访问,从而避免外露端口,以及在服务器IP发生改变时不需要修改调用代码。这方面需要依赖于docker-swarm技术,在上述博客中我也有介绍相关的文章
5. 微服务的分布式转发:RabbitMQ
5.1 RabbitMQ原理简介
RabbitMQ是一个生产者和消费者模型,通过消息队列实现了生产者和消费者之间的异步运行,消费者可以直接将消息丢到消息队列中,再由与消息队列绑定的消费者依据设定的预请求长度
来分配消息。消息队列的关键点是:消息是一次性的,消费完即清除
为了解除消息队列的局限性,RabbitMQ实现了转发器,生产者只需要关注要发布到的转发器,转发器根据关键词将消息复制并转发到多个对应的消息队列,再由与消息队列绑定的消费者进行消费
RabbitMQ实现发布消息与消费消息的异步执行,可以达到解耦,异步,削峰的目的,具体来说就是:
- 在生产者面临大量并发数据时,可以将其不间断地丢给转发器,而不需要逐条处理,因此对于发布者而言,单条数据的处理时间降低,其并发能力大大提升
- 转发器将数据转存至消息队列,消费者可以依据自身实力平稳消费,而不需要担心CPU的过载问题,降低了消费者面对大量并发数据时崩溃的概率(削峰)
- 生产者不需要关心消费者的进度,消费者也不用绑定发布者,一方的修改一般不会引起另一方的重新编译和部署(消费者的处理结果也可以通过逆向创建一个转发器和消息队列告知生产者,从而实现完全解耦)
5.2 SpringAMQP介绍
SpringAMQP是对RabbitMQ的封装,简化了转发器和消息队列的生成,消息的发布和接收,其使用步骤为:
- 在pom文件中导入amqp的起步依赖
- 在application中配置Rabbit的地址,用户名以及密码等信息
- 在主函数中配置
MessageConverter
的替代Bean(可以用Jackson),从而保证对于message实现json数据和java对象的自动转化 - 利用自动装配在生产者中引入
RabbitTemplate
,利用convertAndSend
函数即可发布消息;同时,在消费者中通过@RabbitListener
注解即可完成消息队列与消费者函数的绑定
但是SpringAMQP有一个缺点,它在使用@RabbitListener
注解自动生成转发器,Routekey以及消息队列时,不会自动删除原有的转发器和消息队列(即使代码中已经没有使用),而只会创造新的。这使得我们不可以直接利用删除配置来完成RabbitMQ的更新,这是非常容易出现失误的地方
6. 微服务的分布式搜索库:Elasticsearch
6.1 原理解析
Mysql的CRUD操作都是依据其顺序增加的索引进行的,在Mysql中,顺序索引通过btree数来进行管理,实现n - > log n
的性能优化。
但事实上,在百万,千万级的数据下,顺序索引的查找匹配速度远远满足不了需求;除此之外,Mysql的每一次的查找都是磁盘匹配,磁盘读写速度远慢于内存读写
编程界有一个永恒不变的真理:
当你面对一个难题时,为它加一层新的架构也许就解决问题了
正如名言所云,Elasticsearch对海量数据的搜索优化正是来于加一层:
既然海量数据太大,是绝无可能放入内存进行读写,那么可以提前对海量数据提取term并生成term dict;如果term dict还是太大,还可以对term dict再加一层抽取,生成term index。最后,term index放入内存中,进行快速读取
上述的过程被称为倒排索引,这是Elasticsearch的核心思想,该过程构建后,数据库的索引结构大致变为下面的样式[1]:
第一层抽取放在了内存中,可根据查询关键词快速定位term dict(由于内存的读写速度在100MB/s,即使加上逻辑处理,这个过程也会被控制在微秒级),然后提取完全匹配的dict对应的Posting List,并对多个Posting List进行“与”合并
这里的“与”合并是有说法的,如果按照遍历的思想去做,其复杂度为 O ( N 2 ) O(N^2) O(N2)
而Elasticsearch支持着两种合并方式:
- 使用skip list数据结构进行遍历
- 使用bitset数据结构,进行二进制的AND操作
如果Posting List缓存到了内存,Elasticsearch使用bitset进行合并,其复杂度只有 O ( 1 ) O(1) O(1),反之则利用skip list进行合并,复杂度为 O ( N l o g N ) O(NlogN) O(NlogN)
举个简单的skip list用例,如下图所示的3个Posting List。我们可以选出最短的一个list来做主遍历,把其他两个list转为skip list[2],在这两个skip list上,从上一次访问点开始,用skip list从高level向低level逐步匹配,直至匹配成功或是小于主遍历的值(该过程还可以将不同的skip list放到不同线程上,用并行来进行加速)
6.2 搜索介绍
上一小节只是梳理清楚了Elasticsearch为搜索打下的索引基础,基于此,Elasticsearch的匹配速度是远远超过传统的关系型数据库的。但对于搜索这个问题本身还没有涉及,本小节就搜索这一问题的核心三问,脱开Elasticsearch的束缚而直指搜索的本质,当然,这一过程中也会伴随着一些Elasticsearch对搜索的实践
核心三问:
- 搜索是怎么进行的
- 返回多少搜索结果,匹配关键词有哪些
- 返回结果按照什么顺序展示
只要梳理清楚了核心三问,你对于搜索的本质就有了更深入的了解
- Q1:搜索是怎么进行的?
- A1:我们一般进行的是关键词搜索,即系统根据用户所提供的关键词来一步步地缩小匹配范围,这个过程中,建立有序的关键词队列是关键,这里的“序”指的是该关键词提供的作用,一般分为:
- 全文检索查询:即用户的输入是一段包含关键词的字符串,系统首先自动提取关键词,然后与专用的信息检索字段(一般是多个关键词的整合)做匹配
- 精确查询:即用户指定一个精确查询的值或范围,要求系统必须/选择性(不)包含指定的关键词,系统与对应的字段进行精确地匹配
- A1:我们一般进行的是关键词搜索,即系统根据用户所提供的关键词来一步步地缩小匹配范围,这个过程中,建立有序的关键词队列是关键,这里的“序”指的是该关键词提供的作用,一般分为:
在Elasticsearch中,提供了各种API来创建对应的有序关键词队列。与全文检索查询相关的是: m a t c h _ a l l , m a t c h match\_all, match match_all,match,与精确查询相关的是: t e r m , r a n g e term,range term,range。
在组合多个查询条件时, m u s t , s h o u l d must,should must,should分别代表逻辑与和逻辑或,它们将参与到算分中; m u s t _ n o t , f i l t e r must\_not,filter must_not,filter分别代表与非和与,但特点是不参与算分。
精确查询进行TF-IDF计算是低效的,因此一般通过 m u s t _ n o t , f i l t e r must\_not,filter must_not,filter直接禁止精确查找进行算分过程,可以大大加快查询速度
- Q2:返回多少搜索结果,匹配关键词有哪些?
- A2:限制搜索结果返回数的原因是海量数据的搜索没有必要罗列所有的结果,我们关心的可能仅仅是TopN个,因此我们需要可控的调整搜索返回结果。这就是搜索结果的分页;而除此之外,一个好的搜索行为应该可以明确地标出搜索引擎匹配该条搜索结果的原因,即匹配关键词的高亮显示
在Elasticsearch中,搜索结果的分页由 f r o m from from和 s i z e size size提供,分别代表分页的起始条数和每页条数;而搜索匹配词的高亮则由 h i g h l i g h t highlight highlight提供,需要人工指定哪些关键词匹配后需要做高亮显示,且仅仅是把高亮文本另起一行放在了创建的 h i g h l i g h t highlight highlight中,需要人工提取并替换 s o u r c e source source中的对应字段
- Q3:返回结果按照什么顺序展示?
- A3:
- 方式1:由于全文检索查询时可以设定多个关键词,因此排序时必须有特殊的规则来整合各个条目的不同字段下关键词的匹配结果。最为传统的是 T F − I D F TF-IDF TF−IDF算法,进阶算法则是Elasticsearch中采用的 B M 25 − T F BM25-TF BM25−TF,相关文章那就太多了,想了解的可以移步[1]。整合匹配结果后,给出了各个条目的相关性评分,返回结果按照相关性由高到低的顺序展示
- 方式2:在方式1的基础上,可以自定义打分函数。除计算相关性评分外,还可以根据字段的一些特殊需求(如交了广告费)设置额外的得分函数并与相关性评分融合,从而以人工干预的相关性顺序展示
- 方式3:除了相关性计算和自定义打分函数外,还可以对某个字段或对某几个字段【以某个优先级】进行排序,然后按照排序的顺序展示
- A3:
在Elasticsearch中,默认对全文检索查询实现了 B M 25 − T F BM25-TF BM25−TF评分,也可以通过将 m a t c h match match包裹在 f u n c t i o n _ s c o r e function\_score function_score下来为打分结果增加人工干预函数,最终辅以精确查询的筛选返回结果展示的顺序;如果指定了 s o r t sort sort字段,Elasticsearch将不再计算相关度,而是按照 s o r t sort sort进行结果的返回
相关的代码实践,我将在下一篇博客中进行细致的记录
如果对搜推有所兴趣,个人也有一篇项目导向的博客,可以作为了解[3]
扩展:聚合与自动补全
为了丰富搜索的功能,提升用户的使用体验,搜索一般都会搭配着聚合功能以及自动补全功能
- 聚合功能:聚合一般分为对字段的值或值的录入时间分布的聚合,包括词频统计,最大值、最小值、平均值等等,目的是体现某个字段的值分布规律;由此可以延伸出第二种聚合,它基于第一种的统计结果,对多个字段的值分布进行统计性研究
在elastsearch中,把聚合分为桶聚合(按照字段的值或值的录入时间进行统计并按顺序返回);度量聚合(按照字段的值进行统计值的计算);管道聚合(其他聚合的结果汇总之后再进行新的聚合)
聚合功能很好地满足了我对term dictionary如何获取的好奇心(但对于分词形成的term dictionary仍无法得到)
- 自动补全功能:搜索时,无论用户输入的是首字母拼音,还是全拼,或是汉字,都可以给出相关的“联想”,来帮助用户快速定位想要搜索的内容,这就是自动补全功能。一个在最少的输入下联想出用户需求词条的自动补全功能才是最完美的自动补全功能
elastisearch提供了一个自动补全功能的函数——suggest,但其自动补全时,所有匹配结果的score都是1.0,因此其返回顺序不一定是我们所希望的顺序,可以在得到返回顺序后,通过定制化排序函数来增强自动补全的效果
扩展:elasticsearch集群
一个成熟的微服务,需要自己完成集群(大雾)
虽是玩笑,但微服务的出发点确实是为了利用集群的分布是思维来应对高并发的场景,因此,便捷的集群配置、管理就成了衡量一个微服务技术栈好坏的标准答案
在这方面,elasticsearch是目前所有介绍的技术栈里做的最好的:
-
方便的部署:只需要在
discovery.zen.ping.unicast.hosts
配置中声明集群的所有节点IP与端口,然后在cluster.name
配置中声明集群名称即可完成互联 -
灵活的任务:每个节点都可以通过
node.*=true or false
来配置节点角色,从而分配节点任务,各司其职(master,data,ingest,coordinating) -
智能的分片:节点分开了,数据也要分开存储在各个节点上,为了避免节点宕机而出现数据丢失,每个节点都会额外存储其他节点的分片备份。此外,在分片时,也会通过 h a s h ( _ r o u t i n g ) % s h a r d s hash(\_routing) \% shards hash(_routing)%shards来保证分片数据的均匀分布
- 在一个数据节点(data)宕机后,集群的处理方式是:
- 立即启动该节点分片的备份,将备份升级为主分片,此时集群的主分片数据仍是完整的,集群状态为
yellow
,代表如果继续宕机节点,有可能出现永久性的数据丢失(假如每个节点仅备份一份分片) - 在等待宕机节点超时后,判断节点无法恢复,集群会将该节点所备份的分片重新从对应主分片中提取并存放至存活节点,集群状态恢复为
green
,此时即使继续宕机节点,数据也是完整的 - 在超时时间内宕机节点恢复,集群会将宕机节点对应的主分片和备份分片进行更新,然后再降级之前升级为主分片的对应分片
- 立即启动该节点分片的备份,将备份升级为主分片,此时集群的主分片数据仍是完整的,集群状态为
- 在当选主节点(master)宕机后,集群的处理方式是:
- 从备选主节点中执行选举,只有选票大于等于 ( m a s t e r _ n u m b e r + 1 ) / 2 (master\_number+1)/2 (master_number+1)/2的选举节点才当选,在只有当选主节点出问题时,该方案可以避免当选主节点“假死”的脑裂情形产生(但并没有完全消除)
- 在一个数据节点(data)宕机后,集群的处理方式是:
扩展:elasticsearch和mysql的同步方案
教程中给出了三种elasticsearch和mysql做数据同步的方案:
- 在JAVA业务层执行mysql更新成功后,调用elasticsearch相关的JAVA业务层做相同更新
- 优点:不需要额外的中间件配置
- 缺点:紧耦合,单步更新时间延长 ,必须考虑时序问题,由mysql在确定事务提交成功后,才执行elasti更新
- 添加RabbitMQ中间件,在JAVA业务层执行mysql更新成功后,发布RabbitMQ的Publisher消息,elasticsearch相关的JAVA业务层监听消息队列并做相同更新
- 优点:MQ是成熟高效的中间件,除了消息结构必须保持一致外,基本做到松耦合
- 缺点:必须考虑时序问题,由mysql在确定事务提交成功后,才执行elasticsearch更新
- 利用canal中间件,监听Mysql的binlog并在binlog更新后通知elasticsearch相关的JAVA业务层做相同更新
- 优点:完全松耦合,由于直接监听binlog,因此不需要考虑时序问题
- 缺点:配置最为复杂
需要注意的是,在上述三个方案中我都在强调着时序问题
,这是因为mysql是有事务管理
的,而elasticsearch没有。因此,当JAVA业务层或mysql出现问题从而导致事务回滚时,数据的更新不会记录在mysql中,如果不注意时序,那么就可能出现elasticsearch更新了,而mysql未更新的情况
反之,如果是elasticsearch相关的JAVA业务层出现问题,此时的情况是mysql更新,而elasticsearch未更新或部分更新。如果可以通过代码恢复,就重新执行,否则只能重新从mysql中执行数据导入
在支持事务的数据库和不支持事务的数据库联合使用时,保证前者的数据安全性永远重于后者
7. 微服务的保护技术:Sentinel
sentiel分为sentinel控制台和sentinel客户端,具体的规则过滤都由配置在微服务上的客户端完成,控制台只是进行可视化的规则配置,其逻辑图如下:
由于Sentinel客户端会将规则存储在微服务的运行内存中,因此一旦微服务重启,运行内存即被清空,也就意味着规则的丢失
该问题的解决方案可参照[1],有非常详细的描述:
- 比较简单的解决方案是pull方案,也就是Sentinel客户端将配置规则通过其提供的API缓存到本地文档中,然后每当控制台发起规则更新的命令时,客户端按照先更新内存,再更新本地文档的时序进行。由于是集群部署微服务,因此还会有其他的Sentinel客户端部署,它们将定时轮询本地文档的规则并同步至自身内存
- 优点:实现简单,不需要修改源码与额外的中间件
- 缺点:定时轮询时间点不同带来的各个微服务间配置更新的不同步
因此pull方案适合于规则配置较长时间不变的情况,这时候直接使用pull方案简单快捷
- 比较难的是push方案,控制台将规则推送到nacos或其他远程配置中心。Sentinel客户端链接nacos,获取规则配置,当nacos中的配置规则发生变化,nacos会将变化配置push至各个客户端,客户端就更新本地缓存,从而让本地缓存总是和nacos一致
- 优点:同步不再依赖于定时轮询,而是一有变化就通过push告知,时效性高
- 缺点:需要完全改动控制台,使其不再将配置推送给客户端而是上传nacos
上面我们对流程进行了梳理,同时也深入到了Sentinel使用中的持久化问题,并介绍了相关资料,而对于其推送的配置规则有哪些,即Sentinel实现了哪些功能,我们也将一一罗列:
限流控制
Sentinel可以对访问微服务的流量进行控制,通过设置流控模式(根据谁的QPS,控制谁的访问),流控效果(按照什么原则控制访问)来实现(备注,这里的阈值默认为QPS计数)
- 流控模式: 流控模式说清楚了监控的QPS对象,又根据该对象对谁进行限流
- 直接模式: 统计当前URI资源的请求,超过QPS阈值即对当前资源的访问实行流控
例如: 查询订单业务,当对该业务的请求达到阈值,对访问实行流控 - 关联模式: 配置当前URI资源的关联资源,统计关联资源的请求,超过QPS阈值即对当前资源的访问实行流控
例如: 对查询订单业务建立写入订单业务关联,当写入订单业务的请求达到阈值,为了保证写入顺序,对查询订单业务的访问实行流控 - 链路模式: 当前资源同时作为多个链路的后端节点,此时可以通过指定前端节点的资源名,限制对应该资源的前端节点的访问
例如: 查询订单业务和写入订单业务都以查询商品业务作为后端节点,此时可在查询商品业务配置对查询订单的链路模式限流,保障写入订单业务的顺序查询 - 热点参数限流: 这个并不在流控模式的配置里,但其做的事是和流控模式一样的,所以也把它归纳在流控模式中。
热点参数限流可以对当前资源某个参数进行配置,该参数值相同的访问的QPS不能超过阈值,否则触发访问限制
- 直接模式: 统计当前URI资源的请求,超过QPS阈值即对当前资源的访问实行流控
- 流控效果: 流控效果说清楚了发生限流需求时,应该怎么对待未被处理的请求
- 快速失败: 在检测到QPS达到阈值后,新的请求会被立即拒绝并抛FlowException异常。是默认的处理方式
- warm up: 在检测到QPS达到阈值后,同样拒绝新请求并抛FlowException异常,但阈值是随时间变化的
变化规律: 由参数max_threshold和coldFactor相关的函数确定的,在时间从0至coldFactor过程中,阈值也不断变大并最终达到max_threshold - 排队等待: 在检测到QPS达到阈值后,不直接拒绝新请求,而是将请求以队列存储并按照指定的间隔放出,若队列中有请求的预期等待时间(队长*指定间隔)超过最大时长,则拒绝新请求并抛FlowException异常
与限流对扇入的保护不同,Sentinel还可以对资源的扇出进行隔离与熔断处理,以避免宕机的后端服务造成前端的雪崩
线程隔离
线程隔离即是限制扇出对象的最大线程数,限制的手段可以是通过为扇出对象建立独立的线程池,也可以是设置计数器
- 线程池隔离:即通过为扇出对象建立独立的线程池,来确保不会有单个扇出对象占用所有线程资源,也保证了各个扇出对象的独立
- 优点:由于请求与调用可以不是同一个线程,因此线程池隔离可以支持异步调用,由于请求和调用不是一个线程,所以请求体可以
主动超时
调用线程,从而完成请求的立刻返回(抛出异常) - 缺点:线程的切换将占用cpu资源,拖慢运行速度
- 适用场景:请求的并发量大,并且调用服务时间长,扇出低的场景(异步)
- 优点:由于请求与调用可以不是同一个线程,因此线程池隔离可以支持异步调用,由于请求和调用不是一个线程,所以请求体可以
- 信号量隔离:即通过为扇出对象建立计数器,来确保不会有单个扇出对象占用超过阈值的线程资源
- 优点:由于请求与调用是同一个线程,不需要进行线程的切换而获得了更快的调度速度
- 缺点:请求和调用必须同步完成,不支持异步调用,也不可以对调用进行单独的
主动超时
- 使用场景:请求的并发量大,并且调用服务处理迅速,高频高额扇出的场景(高效)
熔断降级
熔断降级即是在某个扇出对象的不可用调用(这里的不可用调用在Sentinel中分为两种,一种是慢调用,另一种是异常调用)达到一定比例或一定数量时,将对该扇出对象的断路器由closed转为open状态,熔断对该扇出对象的新调用,请求直接返回;降级一段时间后,断路器由open转为half-open状态,开放一次对扇出对象的访问,如果访问可以成功,关闭断路器,否则重新回到open状态,如下图所示:
授权规则
为了避免内部服务器的地址泄露而存在的直接被外网访问的风险,Sentinel客户端可以通过授权规则来把关进入微服务的每一次访问,只有满足授权规则的才予以放行
- 授权规则根据
流控应用名
设定白名单和黑名单 - 只有白名单内的
流控应用
才能访问设定的资源 流控应用名
通过实现RequestOriginParser
接口,实现类中要解析HTTP请求,根据解析内容返回对应流控应用名
,该实现类被装配为Bean
由于自定义异常处理部分的内容不多,而且都是已有知识,所以这里就没有展开介绍该如何实现
8. 微服务的分布式事务:Seata
事务遵循的是ACID的设计原理,用简洁的话来总结就是:一个事物中的所有分支要么同时完成,要么同时失败(这就是原子性
);原子性代表数据间可以保持一致性
,不会存在矛盾现象;事物需要取得数据锁,对同一资源的操作才不会同时发生(隔离性
);事务一旦提交则永久修改,不能回退(持久性
)
正如之前在spring的事务管理中所言,分布式系统中想要完成多个分支的事务管理就必须要声明一个全局的事务管理器来控制分支事务的,这也暗合了我们一直强调的一个观点:凡是难以解决的问题,不妨向上抽取,多加一层架构
然而,简单的抽取一层全局管理器还不行,事务管理中存在一个此消彼长的对弈,全局管理器必须要能够适应不同的对弈需求,这方面Seata是做得最好的。首先,我们想讲讲这个此消彼长的对弈是什么,然后再展开介绍Seata怎么来适应不同的对弈需求的
CAP理论
Partition——分区,代表几方独立运行的系统,这个系统可以是指独立运行的数据库,也可指代由于网络故障而分别提供服务的多个集群,总之,分区意味着系统间无法直接沟通
Avalibility——可用,代表客户端访问健康节点时,节点必须响应,而不是超时或拒绝
Consistency——一致,代表客户端访问分区的各个健康节点时,得到的回应必须一致
我们注意到,一致与可用正是一个此消彼长的对弈,为了更高的一致性,凡是无法沟通的节点在处理事务时,都必须等待进一步的确认才能够回应;反之,为了更高的可用性,必然意味着节点必须首先回应客户端,而不是等待进一步的确认
这正是CAP理论的基础,C与A之间是对立的,但BASE理论告诉我们,对立不意味着不可以折中:
- Basically Available (基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用
- Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态
- Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致
因此我们有基于强一致性的CP方案,也有基于BASE理论的软一致性的AP方案:
- AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致
- CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态
Seata的实践
不论是AP模式还是CP模式,都需要一个全局的事务管理器来进行子事务的注册,状态查询与判断,以及决定事物的回滚与提交,或者通过弥补措施恢复数据。Seata新增了一层事务协调者进行全局事务的注册与管理,不同的全局事务间被有效隔离,其还能够替Seata管理的不同全局事务间添加全局资源锁,避免数据脏写
发生的同时还可以比DB锁更细粒度
的占用资源。Seata由此形成事务协调者、事务管理器、资源管理器的三层架构。我们以XA为例,绘制了该架构
Seata在实践中,为了提供不同的一致性和可用性的平衡需求,分别给出了XA方式和AP方式,两者的区别有:
- XA方式由TC来统一控制事务的提交和回滚,而AP方式由本地资源管理器直接控制事务的提交和回滚。因此XA实现的是强一致性,而AP满足的是软一致性
- XA方式依托于事务型数据库提供的回滚机制来回退修改,而AP方式则依赖于提前备份的资源回退修改。
但事实上,AP也会将占用资源的全局锁持有到最终一致性达成前,也就是我们常说的二阶段完成后,因此,单纯以TC管理的事务而论,AP与XA相比其占用资源的可用性并没有得到提升(这是必须的,否则脏写将无法避免)
AP的可用性提升主要体现在:AP提供的全局锁由TC进行统一的互斥管理,通过xid-tableName-itemId
锁定,只在TC管理的事务内生效。因此,AP会在本地资源管理器执行完事务后直接释放DB锁,允许不被TC管理的其他数据库操作来获取DB锁,而不需要像XA一样等到统一控制完成后才释放DB锁,由此提高了可用性
值得强调的是,由于对于没有经过TM管理的数据库操作,TC是无法进行全局锁的,因此AP释放DB锁后,有极低的概率会被没有经过TM管理的数据库操作破坏一致性,这个时候Seata会记录异常,由人工解决
AP仍然有提升的空间,如果可以想办法在第一阶段就释放全局锁,对于TC管理的事务而言,也将达到最优效果,这就是第三种模式——TCC。
- TCC适用于
修改业务
,新增和删除业务的全局锁在一阶段事务提交后即解除了,因此此时采用AP模式和TCC是一样的效果,还更加方便 - TCC适用于可拆分资源,例如资金或库存这类
数字
资源,而对于字符串
,由于其并不能实现拆分与冻结,因此不适用于TCC
TCC模式的逻辑是,不为TC管理的事务添加全局锁,而是增加一个freeze表记录冻结资源,在try阶段进行资源的预分配,而TC管理器根据所有分支的try阶段执行情况决定对分支事务的调用:
- 如果全局事务执行成功,TC管理器对每个分支事务调用confirm阶段,将预分配变为真实的更新,同时删除freeze表记录,如果该步执行失败,重复尝试直至成功(所以要保证幂等性)
- 如果全局事务执行失败,TC管理器对每个分支事务调用cancel阶段,执行数据的回退(通过freeze表记录执行相反操作,因为不是直接覆盖,所以不会出现数据脏写)如果该步执行失败,重复尝试直至成功(所以要保证幂等性)
try:
if needResource > totalResource - freezeResource: // 避免资源不够冻结
throw exception
if xid exist in freezeTable: // 避免非空悬挂
return;
do:
add: xid-resourceId-needResource-state=0 to freezeTable;
confirm:
do:
update: needResource to resourceTable; // 将真实更新放在confirm阶段,避免数据脏写
delete: xid-resourceId from freezeTable;
cancel:
if xid not exist in freezeTable: // 空回滚
add: xid-resourceId-needResource=0-state=2 to freezeTable;
return;
do:
update: needResource=0-state=2 to freezeTable;
update: needResource to resourceTable;
然而,试想这样一个场景:
- 当事务中某个业务需要执行的时间长达
几天甚至几周
,按照AT或者TCC模式,该事务要么就是长时间的持有全局锁,要么就是知道长时业务完成后,才真正的更新了数据库。这显然启迪着我们,需要一种支持异步操作
的新模式来进行事务管理 - 当事务中某个业务来自于已封装好的API或者是来自于其他公司的服务,
难以拆分
成try和confirm两个阶段来分别执行。这要求我们,需要一种更为直接简洁
的新模式来进行事务管理
这就是saga模式,它抛弃了TCC的confirm
阶段,而是令分支事务在第一阶段
就完成数据库的所有操作,同时记录第一阶段的相反操作,在需要进行第二阶段
时执行回滚。
- saga是异步的,同一事务的不同业务间遵循异步调用原则,前端节点不必像TCC中一样,等待后端完成后再更新数据库
- saga是简洁的,没有confirm阶段,意味着可以直接移植其他公司提供的服务,再编写一个相反逻辑的服务作为第二阶段回滚的依据即可
但同时,saga的优化也带来了不可避免的数据脏写问题
例如银行账户的金额增减,假如初始为100,事务1中的分支1执行+100,成功,此时数据库记录为200,但事务1中分支2失败,失败前事务2中的分支1执行了-150,成功,此时数据库记录为50。由于事务1中分支2失败,事务1的分支1回滚,-100,发现减操作失败,无法回滚。且用户账户由于错误的记录,扣款超过账户额度却成功了
这是一个很严峻的问题,但采用saga模式这一问题确实无法回避的,因此一种广泛采用的解决思路是:
利用设计理念来减弱saga脏写的危害:例如银行账户问题中,我们可以在设计事务执行顺序时,将所有的减操作提前,加操作滞后,即使出现脏写问题,也只会是用户少钱而银行多钱,这种情况可以通过银行赔款来解决,相比案例中用户多钱银行少钱的情况,危害更小
9. 微服务的分布式缓存:Redis
前面的博客都没有系统的对Redis这门技术进行细致的讲解,默认大伙都对这门技术有基本了解,这里就跨越了原理直接来到了Redis的分布式技术上
Redis在单点部署时,面临着任何单点部署服务的共性问题,那就是:
- 服务器宕机时的内存数据丢失
- 高并发场景下的服务器宕机
- 海量缓存数据下的存储容量不足
- 服务器宕机后的服务停止
而要解决单点部署的缺陷,主要的手段就是实现分布式部署,针对以上四个问题,Redis分别设计了各自的解决方案:
- 服务器宕机时的内存数据丢失 —— Redis数据的持久化
- 高并发读场景下的服务器宕机 —— Redis主从结构(主节点写,从节点读,读写分离,提高并发能力)
- 海量缓存数据下的存储容量不足,高并发写场景下的服务器宕机—— Redis分片集群
- 服务器宕机后的服务停止—— Redis哨兵
数据持久化
数据持久化又分为RDB和AOF,简单来说,RDB就是通过fork技术,利用子进程去读取内存数据并在磁盘中生成快照;而AOF则是每执行一条Redis命令,都记录在AOF文件中
以上两种思路各有优劣:
- RDB读取快,但存储慢,而且子进程会定时占用大量cpu和内存消耗,同时,由于fork对主进程写入的保护,子进程存储的数据与主进程是version.k-1和version.k的区别;
- AOF写入快,写入时不占用过多的cpu和内存,而且数据是完整的,但读取时由于需要重新执行AOF记录的所有命令,速度慢
这时候我们就会思考,其实两者做个结合不是很棒吗,RDB继续定时存储,而AOF也进行相关的记录,如果我们可以同步RDB数据的版本与AOF记录的命令的位置,这不就意味着我们重启时可以先载入RDB数据,然后再从同步位置执行AOF命令吗?
虽然仍然没有解决RDB的内存占用问题,但也缓解了RDB的数据不完整,以及AOF载入慢的问题
因此,Redis在设计主从结构时,就结合了RDB和AOF来完成数据的持久化
主从结构
- 阶段一:建立连接,请求数据:
- 在从节点通过
slaveof [master IP][master Port]
向主节点发起从属请求; - 成功建立连接后,从节点会发起增量同步请求,该http请求会携带从节点的replid(每个Redis节点启动后都会动态分配一个40位的十六进制字符串作为运行ID)和offset(默认是0),主节点建立后也有一个自己的replid,当主节点发现两个replid不同时,会拒绝增量同步,向从节点发送自身的replid和offset(从节点由此获得主节点的数据版本信息)
- 在从节点通过
- 阶段二:全量同步:
- 主节点立即fork出子进程存储RDB(该RDB中还包含着主节点的replid和offset)并发送给从节点,从节点清空内存并依照接收的RDB写入缓存信息
- 阶段三:增量同步:
- 主节点将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给从节点,从节点执行接收到的命令,保持与主节点之间的同步
由于阶段二中发送的RDB包含主节点的replid和offset,因此即使从节点重启后,也可以在特定条件下以增量同步方式重新与主节点保持一致:
特定条件为: m a s t e r _ o f f s e t − s l a v e _ o f f s e t < m a x _ l e n g t h ( r e p l _ b a k l o g ) master\_offset-slave\_offset<max\_length(repl\_baklog) master_offset−slave_offset<max_length(repl_baklog)
否则主节点将拒绝从节点的增量同步请求,并执行全量同步
有了主从节点的结构以及其自动执行的数据持久化,我们就开始关心另一个问题了——分布式服务的高可用性。
在主从结构中有一个很大的问题,从节点宕机后立刻重启,系统可以无影响的恢复;但主节点如果宕机,整个Redis结构将无法写入,无法同步,失去了可用性。因此,Redis给出了哨兵机制来解决这个问题
哨兵机制
哨兵的作用体现在三个方面:
- 监控:通过配置哨兵集群,利用心跳检测和主客观下线机制,监控master和slave的状态
- 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
- sentinel给备选的slave1节点发送
slaveof no one
命令,让该节点成为master - sentinel给所有其它slave发送
slaveof [master IP][master Port]
命令,让这些slave成为新master的从节点,开始从新的master上同步数据 - 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点
- sentinel给备选的slave1节点发送
- 通知:sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将新的master和slave地址推送给Redis的客户端
Redis分片集群
正如前文所述,Redis主从结构和sentinel只是保证了高读取并发时的系统稳定性,而没有保证高写入并发下,以及海量存储需求下的系统稳定性
-
分片集群与散列插槽:遇事不决加一层,多个主从结构组成新的分片集群,主节点间通过均衡的分配散列插槽(也可以根据主节点处理能力有权重的分配),实现存储数据的分片以及对高写入并发的负载均衡
-
天然的哨兵机制与故障转移:由于多个主节点间互相进行心跳检测,因此天然的构成了哨兵机制,可自动完成故障转移(转移机制和哨兵类似,在发现某个master节点客观下线后,选举该分片下优先级最高的slave升为master节点,原master节点置为slave)。除自动转移外,也可以在对应的slave节点上执行cluster failover命令,通过下图所示的六个步骤将无感知的将slave升级为master
-
存储数据的位置分配算法:对存储数据的key进行keylen次crc16操作,返回值对16383(源码地址)取余,结果作为散列插槽值。该算法的好处是保证键均匀的分配到各个槽位上
10. 分布式缓存进阶:多级缓存机制
多级缓存一般是指:
- 第一级:Nginx级
- 缓存方式:Nginx服务器内存缓存
- 更新方式:通过超时机制实现缓存数据更新
- 实现方式:OpenResty工具
- 第二级:Redis级
- 缓存方式:Redis服务器内存缓存
- 更新方式:
- 对于同步准确率要求一般的数据,采用异步通知的方式更新
- 可选择MQ方式或者是Canal方式
- 对于同步准确率要求高的数据,采用事务管理的方式更新
- 对于同步准确率要求一般的数据,采用异步通知的方式更新
- 实现方式:Redis
- 第三级:Tomcat级
- 缓存方式:JVM进程本地内存缓存
- 更新方式:同Redis一致
- 实现方式:Caffine
多级缓存在实现中需要注意以下几点:
- 由于是集群部署,而缓存信息在每台主机的本地,因此在做集群的负载均衡时,应通过
Hash
处理请求的URI,结果对集群数取余后,决定要代理向的服务器,确保同一URI访问同一台服务器- 有关于nginx的一般Hash和一致Hash,可参考[1],有很多头头道道
- 由于OpenResty不方便实现异步通知或同步更新,只能采用超时更新,因此在Nginx服务器上缓存的数据应尽量是那种长时间无变化的稳定数据
多级缓存结构图:
11. RabbitMQ进阶内容
RabbitMQ消息可靠性
RabbitMQ为实现消息的可靠性,主要做了三个方面的工作:
- 生产者确认
-
机制:生产者在发送消息后,需要监听MQ的回执,包括两种类型三种可能:
- publish confirm:
- publish confirm & ack:消息到达了MQ并且发送到队列
- publish confirm & nack:消息没有到达MQ
- publish return & ack:消息到达了MQ但是没有发送到队列
- publish confirm:
-
如果接收到nack或者publish return回执,则考虑通过重发数据,通知运维人员,记录错误日志等方式,维护生产者消息的可靠性
-
- MQ持久化
- 机制:MQ将交换机,队列以及消息存储在磁盘中,实现数据的持久化
- 消费者确认
- 机制:MQ在将消息推送给消费者后,需要等待消费者的回执,消费者回执有多种可能:
- 如果消费者消费成功,MQ得到ack回执,删除消息
- 如果消费者第一次消费不成功,消费者会调用spring的retry机制重试数次,如果达到上限仍然不成功,消费者可选择:
- 返回reject回执,MQ得到reject回执,拒绝并删除消息
- 返回nack回执,MQ得到nack回执,消息重新入队并再次发给消费者
- 失败消息发送到MQ的error message queue(自己创建)留存
- 机制:MQ在将消息推送给消费者后,需要等待消费者的回执,消费者回执有多种可能:
死信交换机与延时消息
死信交换机,顾名思义,就是用来转发RabbitMQ因各种原因产生的死消息的交换机,这里的各种原因包括:
- 消费者返回reject回执,或返回nack回执但队列不允许重新入队时
- 消息等待消费时间超过设定时间(可以由发送者设定超时时间,也可以设定队列超时时间,两者中较短的生效)
- 消息队列满额,在等待刷出过程中到来的新消息无法投递
延时消息:
- 可以利用死信原理,通过为消息队列绑定死信交换机,为发送的消息绑定超时时间,消费者监听死信队列,即可以在超时时间后读取到消息,实现消息的延时
- 除此之外,DelayExchange插件提供了一种新的交换机,可以在交换机处将消息主动延时,超时后再发送到对应队列,避免了多设计一条交换机和队列,使用更为方便
惰性队列
消息队列满额后,RabbitMQ会将一部分内存消息刷出到磁盘中,清理出空间,但该过程需要时间,这意味着如果发送消息速度高于消费消息速度:
- RabbitMQ的处理能力将呈现锯齿状,不够平滑
- 如果在刷出过程中到来新消息,该消息将成为死信,无法被消息队列接收
为了处理这个问题,RabbitMQ提出一种惰性队列机制,消息不再存储在内存中,而是全部刷出到磁盘里,等消费者确定消费时,才从磁盘里刷回内存
MQ集群知识
MQ集群包括三类:
- 普通集群:集群间只共享交换机和队列信息,其他队列仅知道某个消息在哪个队列并可以负责转发,但并不能处理,一旦该队列宕机,消息将无法获取
- 镜像集群:集群间互相进行消息的备份,由得到该消息的队列为主,其他队列为镜像,一切对该消息的访问都必须转发到主节点处理
- 仲裁队列:仲裁队列只是针对特定的队列而言,其是镜像队列的扩展,在镜像队列的基础上,通过Raft协议保证强一致性(从节点操作日志必须与主节点一致,不一致