Java微服务技术栈不像Java web笔记和SSM一样了,微服务技术栈涉及到的第三方技术太多了,为了方便以后的查找,不能再像Java web笔记和SSM笔记一样,着重记原理,代码记得少。这部分原理和代码基本处于同一比重,好了,让我们以下面这张图,开始我们的微服务技术栈之旅吧
我们设想,部署在两台服务器上的不同业务,想要互相进行业务的访问可以怎么办?正如访问页面的跳转一般,在Java后台,我们也可以通过RestTemplete
发起HTTP请求,并接收返回包。RestTemplete是智能的,你仍然可以通过传入类的class属性,来完成由SpringBoot框架返回的Json数据的自动赋值。上述过程可以总结为如下的代码:
// in order application, add the bean of restTemplete
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
// in order service, autowire the bean and use the function of http request to view the service on other server
User user = restTemplate.getForObject("http://localhost:8081/user/" + order.getUserId(), User.class);
我们可以发现,虽然SpringBoot框架提供了RestTemplete
这样的类来辅助进行多台服务器间的独立部署,但这种侵入式的编程实在是不雅观。比如一台服务器的项目发生迁移,地址改变,另一台服务器也必须重新部署。
因此,一个合理的思路就是:
建立一个专门的注册中心,注册中心的任务是:
- 为服务器的地址信息以及专用名建立字段,服务器间通过专用名向注册中心请求地址并相互访问
- 维护服务器存活信息,eureka要求服务器每隔30s进行心跳续约,保证注册中心中的服务器都是可访问的
这就是eureka完成注册管理工作的原理,而具体的使用方式则分为三个步骤
/***
1. 搭建EurekaServer
* 引入eureka-server依赖
* 添加@EnableEurekaServer注解
* 在application中配置eureka地址
2. 服务注册
* 引入eureka-client依赖
* 在application中配置eureka地址
3. 服务发现
* 给RestTemplete添加@LoadBalance注解
* 用服务提供者的服务名远程调用
***/
// 服务注册
// in order,user,eureka application.yml
server:
port: 10086 # 服务端口
spring:
application:
name: eurekaserver # eureka的服务名称
eureka:
client:
service-url: # eureka的地址信息
defaultZone: http://127.0.0.1:10086/eureka
nacos集成了eureca的思想和操作方式,由于其服务器启动于命令行,所以不需要在项目里添加服务器模块,只需要添加客户端依赖即可
nacos相比于eureca的优势主要体现在它对服务器的主动性:(1)对于消费者增加了主动推送变更信息的功能,当有服务器done机时,消费者可以更快知道;(2)对于生产者增加临时实例和非临时实例的区别,临时实例采用被动的心跳检测,并且在done机后剔除服务器,非临时实例采用主动的问询,在done机后不会主动剔除,而是等待服务器恢复正常
nacos的工作流程如下图所示(比eureka多了push线)
由于微服务架构中,服务器由注册中心进行域名的管理,所以在进行负载均衡时,服务消费者需要和注册中心进行拉取才能调用负载均衡算法。因此,有关于ribbon负载均衡的内容也记录在Section 2中
负载均衡有很多相关算法,如常见的随机选择,轮循选择,或者是基于地域与权重的优先度选择等等
ribbon中集成了很多相关的负载均衡算法,通过IRule属性进行控制(因此,一种修改默认负载均衡算法的方法就是直接填充一个返回值类型为IRule的Bean),有关于ribbon集成的负载均衡算法可以参看下面的博客:Ribbon七种负载均衡算法
除了通过添加Bean外,还可以在application.yml中,为特定的域名服务器新增IRule规则修改,从而实现精准控制
userservice:
ribbon:
NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule # 负载均衡规则
nacos还实现了一个配置管理中心,服务器可以把需要进行热更新的配置信息放在nacos里,当该配置发生变化的时候,nacos会主动通告与该配置关联的服务器,如果服务器端读取时设置了热更新,那么配置信息就可以直接应用于服务器
在nacos中添加配置文件需要设置Data ID
,这个值必须唯一且按照标准格式命名:
[service name]-[profile].[yaml or properties] # 第一种nacos配置文件,精准关联不同环境的服务
[service name].[yaml or properties] # 第二种nacos配置文件,关联所有环境的同名服务
配置信息优先级:第一种>第二种(范围越小,优先级越高) 远端>本地(热更新高于静态配置)
首先要明确,热更新文件是建立在nacos中,但对热更新文件的关联是来自于服务器端的bootstrap.yaml
配置。因此nacos只会向完全匹配的服务器推送对应的配置更新。服务器端bootstrap.yaml
配置内容为:
spring:
application:
name: [service_name]
profiles:
active: [profile] # 环境
cloud:
nacos:
server-addr: [nacos_addr]:[port] # nacos地址
config:
file-extension: yaml # 文件后缀名
# 读取顺序bootstrap>nacos远端配置文件>application,因此在bootstrap进行配置可以让项目知道nacos的地址信息
nacos的集群,或者说集群这个概念本身就很有意思,今天借着Section 2.5,我们就来浅析一下集群和单点所不同的工作。
首先,我们根据下图所示建立了一个3节点组成的nacos集群
要理解集群,首先必须认识到等价节点的信息一致性,与单点工作不同,单点是独立存储运行信息,而在集群中,等价节点能接收到的信息是一致的。如上图所示,三个nacos节点连在同一个数据库集群且访问无限制区别,所以这三个节点是等价的。
信息一致性的好处就是,nginx做负载均衡的时候,只需要根据负载均衡规则选择执行任务的节点,而不需要担心它是否能执行这个任务。
我们结合Section 2.2中nacos注册中心的业务来进一步说明集群的工作:
总结:
nacos集群的建立不难,首先是三个nacos节点的部署,然后在nginx中声明监听和反向代理的ip地址即可,需要注意的是,nacos节点部署时需要对application.properties中数据库进行配置,以及在cluster.conf中声明集群所有节点ip地址才能运行start命令
nginx的配置内容如下:
upstream nacos-cluster {
server [nacos_server_1_name:port];
server [nacos_server_2_name:port];
server [nacos_server_3_name:port];
}
server {
listen 80;
server_name [cluster_server_name];
location /nacos {
proxy_pass http://nacos-cluster;
}
}
附一个批量启动的cmd代码,这样就不用苦哈哈手动启动了:
D:
cd D:\nacos1\bin
start startup.cmd
cd D:\nacos2\bin
start startup.cmd
cd D:\nacos3\bin
start startup.cmd
cd D:\nginx-1.18.0
start nginx.exe
顺便扩展一下,如果对nginx感兴趣的可以移步我的另一篇博客:nginx原理浅述与配置总结
服务消费者在获取到生产者地址后,还需要通过客户端来发送HTTP请求,以完成消费任务
基础的方式如Section 2.1中所介绍的,使用RestTemplete类来发送HTTP请求。但由于其在编写复杂请求的URL路径上的困难,我们更倾向于使用与SpringMVC有相同逻辑的feign来实现客户端请求
为了实现代码的复用,消费被抽取为一个独立的feign-api来管理,消费者可调用该jar包来选择对应的消费功能;同时,为了减少每一次请求都重新建立TCP的三次握手和四次挥手,我们采用连接池来优化feign的性能
上述功能在具体实现上,体现为如下步骤:
@FeignClient(value = [service_name])
的注解@EnableFeignClients(clients = {[client_name].class}, defaultConfiguration = [config_name].class)
注解,生成符合[config_name]
的[client_name]
的Bean实例[client_name]
的Bean实例并进行消费一些相关的代码如下:
feign-api的前置依赖:
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
dependency>
<dependency>
<groupId>io.github.openfeigngroupId>
<artifactId>feign-httpclientartifactId>
dependency>
消费者中进行feign连接池的启用配置:
feign:
httpclient:
enabled: true # 支持HttpClient的开关
max-connections: 200 # 最大连接数
max-connections-per-route: 50 # 单个路径的最大连接数
gatway设置了10多种路由工厂,可以设置不同的条件,只有满足筛选条件的才由Gateway路由到对应的服务器地址,路由工厂的详细说明可以参看官方中文文档:gateway路由断言工厂
由于需要路由到服务器,所以gateway模块也需要在nacos中进行注册
这里我们简单举一个路径断言工厂在application.yml中的配置方案:
spring:
cloud:
gateway:
routes:
- id: path_route # 唯一标识id
uri: lb://orderservice # 要路由的nacos中的服务器名
predicates:
- Path=/order/{segment} # 请求URI路径需要满足的条件
过滤分为默认过滤器(default-filters),相关路由过滤器(filters),全局过滤器(GlobalFilter),它们都是加载在路由之后的执行器,如下图所示:
其中,默认过滤器和相关路由过滤器只是影响的路由范围不同,但底层实现都是各种各样的GatewayFilter工厂所创建的GatewayFilter实例。工厂的详细描述可以参见:GatewayFilter工厂。
而GlobalFilter是一个提供filter方法的接口,filter方法如下:
// exchange是融合了request信息和response信息的实例,chain自动移动的过滤器链
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("custom global filter");
return chain.filter(exchange);
}
依赖exchange,我们可以做很多过滤逻辑,例如,想对没有登陆的用户返回登陆服务器的重定向信息,只需要向exchange的response写内容返回即可
ServerHttpResponse response = exchange.getResponse();
String redirectUrl = [主机名]/[login];
logger.info("bmg 重定向到URL: {}", redirectUrl);
response.getHeaders().set(HttpHeaders.LOCATION, redirectUrl);
//303状态码表示由于请求对应的资源存在着另一个URI,应使用GET方法定向获取请求的资源
response.setStatusCode(HttpStatus.SEE_OTHER);
response.getHeaders().add("Content-Type", "text/plain;charset=UTF-8");
return response.setComplete();
过滤器间首先以优先级做排序,其中默认过滤器和相关路由过滤器都是在各自的集合内以1为优先级顺延的;在优先级相同的情况下,再按照默认过滤器->相关路由过滤器->全局过滤器排列。
跨域指的是浏览器在访问某个URL中的ajax请求时,发现请求的URL路径与当前正在访问的URL要么域名不同,要么端口不同。这种情况下浏览器会拒绝本次ajax请求,这就是跨域问题
要解决跨域问题,只需要浏览器在访问某个URL时,由对应的服务器告知浏览器该URL访问可以跨域即可,这就是CORS
在Gateway中,通过配置形式实现了CORS,配置详情可参照:Gateway CORS
Docker个人觉得是微服务技术栈里很有创新的一个功能了,它针对各种服务的依赖不同,编译测试环境不同而导致的部署困难,无法跨环境(例如当服务的依赖编译自Ubuntu,就无法迁移到Centos,因为调用函数不同),提出了很巧妙的解决方案:
- 将一个个的服务封装为镜像,镜像包含服务所需的所有依赖,这里的依赖不仅指第三方工具,还指工具所依赖的系统函数。
- 通过容器启动镜像,可以不依赖于当前系统的函数,而是直接通过封装的系统函数去调用内核。即不论编译成镜像时的环境如何,启动该镜像的容器都是可以直接运行在任何Linux系统的
Docker的解决方案中有两个重要的内容,镜像以及启动镜像的容器,下面我们将一一进行介绍
镜像在docker中就是服务封装的别称,一个镜像代表着一个部署后可独立自主运行的服务。而镜像部分最关键的知识就是镜像的生成。只有了解镜像是如何生成的,我们才能把我们自己的微服务给部署到docker中
镜像就是用来告诉docker要怎么来启动打包的服务:
以Java项目构建镜像为例,一个可行的Dockerfile的结构一般和下面类似:
# 指定基础镜像
FROM ubuntu:16.04
# 配置环境变量,JDK的安装目录
ENV JAVA_DIR=/usr/local
# 拷贝jdk和java项目的包
COPY ./jdk8.tar.gz $JAVA_DIR/
COPY ./docker-demo.jar /tmp/app.jar
# 安装JDK
RUN cd $JAVA_DIR \
&& tar -xf ./jdk8.tar.gz \
&& mv ./jdk1.8.0_144 ./java8
# 配置环境变量
ENV JAVA_HOME=$JAVA_DIR/java8
ENV PATH=$PATH:$JAVA_HOME/bin
# 暴露端口
EXPOSE 8090
# 入口,java项目的启动命令
ENTRYPOINT java -jar /tmp/app.jar
由于环境变量配置,JDK导入和安装等等都是重复性工作,所以Docker中又提供了一个快速配置的镜像,引入该镜像后,Dockerfile只需要配置拷贝java项目和入口即可,可以很好的简化镜像生成
FROM java:8-alpine
COPY ./app.jar /tmp/app.jar
EXPOSE 8090
ENTRYPOINT java -jar /tmp/app.jar
容器是启动镜像的平台,内部是一个简化版的linux系统(即只包含了镜像运行所需的函数库与文件夹)
一般来说,通过docker run命令启动的容器默认位于子网bridge,默认启动的各容器可以直接通过名称进行相互之间的访问(不用向外暴露端口)
还可以通过docker-compose批量启动容器,docker-compose会自动创建虚拟子网并把批启动的容器放在同一子网管辖,因此它们也可以通过名称互相访问
容器的子网是可以通过docker network命令改变的,也可以在docker-compose启动时选择子网,从而保证启动方式不同的各个容器处于同一子网下,这方面可移步我的另一篇博客:通过docker-compose完成负载均衡的nacos集群,里面有更细的原理和实验展示
除了同一服务器上部署容器时的名称访问需求外,对于在不同服务器上进行的集群部署,我们也希望它们之间可以实现名称访问,从而避免外露端口,以及在服务器IP发生改变时不需要修改调用代码。这方面需要依赖于docker-swarm技术,在上述博客中我也有介绍相关的文章
RabbitMQ是一个生产者和消费者模型,通过消息队列实现了生产者和消费者之间的异步运行,消费者可以直接将消息丢到消息队列中,再由与消息队列绑定的消费者依据设定的预请求长度
来分配消息。消息队列的关键点是:消息是一次性的,消费完即清除
为了解除消息队列的局限性,RabbitMQ实现了转发器,生产者只需要关注要发布到的转发器,转发器根据关键词将消息复制并转发到多个对应的消息队列,再由与消息队列绑定的消费者进行消费
RabbitMQ实现发布消息与消费消息的异步执行,可以达到解耦,异步,削峰的目的,具体来说就是:
SpringAMQP是对RabbitMQ的封装,简化了转发器和消息队列的生成,消息的发布和接收,其使用步骤为:
MessageConverter
的替代Bean(可以用Jackson),从而保证对于message实现json数据和java对象的自动转化RabbitTemplate
,利用convertAndSend
函数即可发布消息;同时,在消费者中通过@RabbitListener
注解即可完成消息队列与消费者函数的绑定但是SpringAMQP有一个缺点,它在使用@RabbitListener
注解自动生成转发器,Routekey以及消息队列时,不会自动删除原有的转发器和消息队列(即使代码中已经没有使用),而只会创造新的。这使得我们不可以直接利用删除配置来完成RabbitMQ的更新,这是非常容易出现失误的地方
所以如果想要批量清除原有配置,进入RabbitMQ容器后,使用以下命令:
[root@centos7 ~]# docker exec -it server-rabbit /bin/bash
root@my-rabbit:/# rabbitmqctl stop_app
root@my-rabbit:/# rabbitmqctl reset
root@my-rabbit:/# rabbitmqctl start_app
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支持着两种合并方式:
如果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放到不同线程上,用并行来进行加速)
上一小节只是梳理清楚了Elasticsearch为搜索打下的索引基础,基于此,Elasticsearch的匹配速度是远远超过传统的关系型数据库的。但对于搜索这个问题本身还没有涉及,本小节就搜索这一问题的核心三问,脱开Elasticsearch的束缚而直指搜索的本质,当然,这一过程中也会伴随着一些Elasticsearch对搜索的实践
核心三问:
只要梳理清楚了核心三问,你对于搜索的本质就有了更深入的了解
在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直接禁止精确查找进行算分过程,可以大大加快查询速度
在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中的对应字段
在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是目前所有介绍的技术栈里做的最好的:
方便的部署:只需要在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来保证分片数据的均匀分布
yellow
,代表如果继续宕机节点,有可能出现永久性的数据丢失(假如每个节点仅备份一份分片)green
,此时即使继续宕机节点,数据也是完整的教程中给出了三种elasticsearch和mysql做数据同步的方案:
需要注意的是,在上述三个方案中我都在强调着时序问题
,这是因为mysql是有事务管理
的,而elasticsearch没有。因此,当JAVA业务层或mysql出现问题从而导致事务回滚时,数据的更新不会记录在mysql中,如果不注意时序,那么就可能出现elasticsearch更新了,而mysql未更新的情况
反之,如果是elasticsearch相关的JAVA业务层出现问题,此时的情况是mysql更新,而elasticsearch未更新或部分更新。如果可以通过代码恢复,就重新执行,否则只能重新从mysql中执行数据导入
在支持事务的数据库和不支持事务的数据库联合使用时,保证前者的数据安全性永远重于后者