Note/青空笔记/SpringCloud笔记/SpringCloud笔记(三).md

1980 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 微服务应用
前面我们已经完成了SpringCloudAlibaba的学习我们对一个微服务项目的架构体系已经有了一定的了解那么本章我们将在应用层面继续探讨微服务。
## 分布式权限校验
虽然完成前面的部分,我们已经可以自己去编写一个比较中规中矩的微服务项目了,但是还有一个问题我们没有解决,登录问题。假如现在要求用户登录之后,才能进行图书的查询、借阅等操作,那么我们又该如何设计这个系统呢?
回顾我们之前进行权限校验的原理,服务器是如何判定一个请求是来自哪个用户的呢?
* 首先浏览器会向服务端发送请求,访问我们的网站。
* 服务端收到请求后会创建一个SESSION ID并暂时存储在服务端然后会发送给浏览器作为Cookie保存。
* 之后浏览器会一直携带此Cookie访问服务器这样在收到请求后就能根据携带的Cookie中的SESSION ID判断是哪个用户了。
* 这样服务端和浏览器之间可以轻松地建立会话了。
但是我们想一下,我们现在采用的是分布式的系统,那么在用户服务进行登录之后,其他服务比如图书服务和借阅服务,它们会知道用户登录了吗?
![image-20220401220634827](https://tva1.sinaimg.cn/large/e6c9d24ely1h0uko0a27mj21ia08y0u9.jpg)
实际上我们登录到用户服务之后Session中的用户数据只会在用户服务的应用中保存而在其他服务中并没有对应的信息但是我们现在希望的是所有的服务都能够同步这些Session信息这样我们才能实现在用户服务登录之后其他服务都能知道那么我们该如何实现Session的同步呢
1. 我们可以在每台服务器上都复制一份Session但是这样显然是很浪费时间的并且用户验证数据占用的内存会成倍的增加。
2. 将Session移出服务器用统一存储来存放比如我们可以直接在Redis或是MySQL中存放用户的Session信息这样所有的服务器在需要获取Session信息时统一访问Redis或是MySQL即可这样就能保证所有服务都可以同步Session了是不是越来越感觉只要有问题没有什么是加一个中间件解决不了的
![image-20220402111827054](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v7jwaum4j21qk0amq4h.jpg)
那么我们就着重来研究一下然后实现2号方案这里我们就使用Redis作为Session统一存储我们把一开始的压缩包重新解压一次又来从头开始编写吧。
这里我们就只使用Nacos就行了和之前一样我们把Nacos的包导入一下然后进行一些配置
![image-20220402105512397](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v6vpkivcj22ca0baju3.jpg)
现在我们需要为每个服务都添加验证机制,首先导入依赖:
```xml
<!-- SpringSession Redis支持 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<!-- 添加Redis的Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
```
然后我们依然使用SpringSecurity框架作为权限校验框架
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
```
接着我们在每个服务都编写一下对应的配置文件:
```yaml
spring:
session:
# 存储类型修改为redis
store-type: redis
redis:
# Redis服务器的信息该咋写咋写
host: 1.14.121.107
```
这样默认情况下每个服务的接口都会被SpringSecurity所保护只有登录成功之后才可以被访问。
我们来打开Nacos看看
![image-20220402105638986](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v6x7rkvuj22cm09sq4s.jpg)
可以看到三个服务都正常注册了,接着我们去访问图书服务:
![image-20220402105803658](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v6yoo6hbj226g0jkjsy.jpg)
可以看到,访问失败,直接把我们给重定向到登陆页面了,也就是说必须登陆之后才能访问,同样的方式去访问其他服务,也是一样的效果。
由于现在是统一Session存储那么我们就可以在任意一个服务登录之后其他服务都可以正常访问现在我们在当前页面登录登录之后可以看到图书服务能够正常访问了
![image-20220402110245827](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v73kpsaaj219u06a0tg.jpg)
同时用户服务也能正常访问了:
![image-20220402110328674](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v74bgewzj216m06874r.jpg)
我们可以查看一下Redis服务器中是不是存储了我们的Session信息
![image-20220402110416031](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v754p9ihj20z8048aax.jpg)
虽然看起来好像确实没啥问题了,但是借阅服务炸了,我们来看看为什么:
![image-20220402110519893](https://tva1.sinaimg.cn/large/e6c9d24ely1h0v768pgt2j21sk096q7k.jpg)
在RestTemplate进行远程调用的时候由于我们的请求没有携带对应SESSION的Cookie所以导致验证失败访问不成功返回401所以虽然这种方案看起来比较合理但是在我们的实际使用中还是存在一些不便的。
***
## OAuth 2.0 实现单点登录
**注意:**第一次接触可能会比较难,不太好理解,需要多实践和观察。
前面我们虽然使用了统一存储来解决Session共享问题但是我们发现就算实现了Session共享依然存在一些问题由于我们每个服务都有自己的验证模块实际上整个系统是存在冗余功能的、同时还有我们上面出现的问题那么能否实现只在一个服务进行登录就可以访问其他的服务呢
![image-20220408223238760](https://tva1.sinaimg.cn/large/e6c9d24ely1h12or8uglrj21s20gawh8.jpg)
实际上之前的登录模式称为多点登录,而我们希望的是实现单点登陆,因此,我们得找一个更好的解决方案。
这里我们首先需要了解一种全新的登录方式:**OAuth 2.0**我们经常看到一些网站支持第三方登录比如淘宝、咸鱼我们就可以使用支付宝进行登录腾讯游戏可以用QQ或是微信登陆以及微信小程序都可以直接使用微信进行登录。我们知道它们并不是属于同一个系统比如淘宝和咸鱼都不属于支付宝这个应用但是由于需要获取支付宝的用户信息这时我们就需要使用 OAuth2.0 来实现第三方授权,基于第三方应用访问用户信息的权限(本质上就是给别人调用自己服务接口的权限),那么它是如何实现的呢?
### 四种授权模式
我们还是从理论开始讲解OAuth 2.0一共有四种授权模式:
1. **客户端模式Client Credentials**
这是最简单的一种模式我们可以直接向验证服务器请求一个Token这里可能有些小伙伴对Token的概念不是很熟悉Token相当于是一个令牌我们需要在验证服务器**User Account And Authentication**服务拿到令牌之后,才能去访问资源,比如用户信息、借阅信息等,这样资源服务器才能知道我们是谁以及是否成功登录了)
当然,这里的前端页面只是一个例子,它还可以是其他任何类型的**客户端**比如App、小程序甚至是第三方应用的服务。
![image-20220409213716233](https://tva1.sinaimg.cn/large/e6c9d24ely1h13srxpu4pj21720940tz.jpg)
虽然这种模式比较简便,但是已经失去了用户验证的意义,压根就不是给用户校验准备的,而是更适用于服务内部调用的场景。
2. **密码模式Resource Owner Password Credentials**
密码模式相比客户端模式就多了用户名和密码的信息用户需要提供对应账号的用户名和密码才能获取到Token。
![image-20220409213646255](https://tva1.sinaimg.cn/large/e6c9d24ely1h13srezf2xj218k09c0u6.jpg)
虽然这样看起来比较合理,但是会直接将账号和密码泄露给客户端,需要后台完全信任客户端不会拿账号密码去干其他坏事,所以这也不是我们常见的。
3. **隐式授权模式Implicit Grant**
首先用户访问页面时会重定向到认证服务器接着认证服务器给用户一个认证页面等待用户授权用户填写信息完成授权后认证服务器返回Token。
![image-20220409211722092](https://tva1.sinaimg.cn/large/e6c9d24ely1h13s7a0nxzj21ey0da0uw.jpg)
它适用于没有服务端的第三方应用页面并且相比前面一种形式验证都是在验证服务器进行的敏感信息不会轻易泄露但是Token依然存在泄露的风险。
4. **授权码模式Authrization Code**
这种模式是最安全的一种模式也是推荐使用的一种比如我们手机上的很多App都是使用的这种模式。
相比隐式授权模式它并不会直接返回Token而是返回授权码真正的Token是通过应用服务器访问验证服务器获得的。在一开始的时候应用服务器客户端通过访问自己的应用服务器来进而访问其他服务和验证服务器之间会共享一个`secret`,这个东西没有其他人知道,而验证服务器在用户验证完成之后,会返回一个授权码,应用服务器最后将授权码和`secret`一起交给验证服务器进行验证并且Token也是在服务端之间传递不会直接给到客户端。
![image-20220409223317823](https://tva1.sinaimg.cn/large/e6c9d24ely1h13ue89sjxj21dg0fq40y.jpg)
这样就算有人中途窃取了授权码也毫无意义因为Token的获取必须同时携带授权码和secret但是`secret`第三方是无法得知的并且Token不会直接丢给客户端大大减少了泄露的风险。
但是乍一看OAuth 2.0不应该是那种第三方应用为了请求我们的服务而使用的吗,而我们这里需要的只是实现同一个应用内部服务之间的认证,其实我也可以利用 OAuth2.0 来实现单点登录,只是少了资源服务器这一角色,客户端就是我们的整个系统,接下来就让我们来实现一下。
### 搭建验证服务器
第一步就是最重要的我们需要搭建一个验证服务器它是我们进行权限校验的核心验证服务器有很多的第三方实现也有Spring官方提供的实现这里我们使用Spring官方提供的验证服务器。
这里我们将最开始保存好的项目解压就重新创建一个新的项目首先我们在父项目中添加最新的SpringCloud依赖
```xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
```
接着创建一个新的模块`auth-service`,添加依赖:
```xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2.0依赖,不再内置了,所以得我们自己指定一下版本 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
</dependencies>
```
接着我们修改一下配置文件:
```yaml
server:
port: 8500
servlet:
#为了防止一会在服务之间跳转导致Cookie打架因为所有服务地址都是localhost都会存JSESSIONID
#这里修改一下context-path这样保存的Cookie会使用指定的路径就不会和其他服务打架了
#但是注意之后的请求都得在最前面加上这个路径
context-path: /sso
```
接着我们需要编写一下配置类这里需要两个配置类一个是OAuth2的配置类还有一个是SpringSecurity的配置类
```java
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated() //
.and()
.formLogin().permitAll(); //使用表单登录
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
auth
.inMemoryAuthentication() //直接创建一个用户,懒得搞数据库了
.passwordEncoder(encoder)
.withUser("test").password(encoder.encode("123456")).roles("USER");
}
@Bean //这里需要将AuthenticationManager注册为Bean在OAuth配置中使用
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
```
```java
@EnableAuthorizationServer //开启验证服务器
@Configuration
public class OAuth2Configuration extends AuthorizationServerConfigurerAdapter {
@Resource
private AuthenticationManager manager;
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
/**
* 这个方法是对客户端进行配置,一个验证服务器可以预设很多个客户端,
* 之后这些指定的客户端就可以按照下面指定的方式进行验证
* @param clients 客户端配置工具
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
.inMemory() //这里我们直接硬编码创建当然也可以像Security那样自定义或是使用JDBC从数据库读取
.withClient("web") //客户端名称,随便起就行
.secret(encoder.encode("654321")) //只与客户端分享的secret随便写但是注意要加密
.autoApprove(false) //自动审批,这里关闭,要的就是一会体验那种感觉
.scopes("book", "user", "borrow") //授权范围这里我们使用全部all
.authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
//授权模式一共支持5种除了之前我们介绍的四种之外还有一个刷新Token的模式
//这里我们直接把五种都写上,方便一会实验,当然各位也可以单独只写一种一个一个进行测试
//现在我们指定的客户端就支持这五种类型的授权方式了
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security
.passwordEncoder(encoder) //编码器设定为BCryptPasswordEncoder
.allowFormAuthenticationForClients() //允许客户端使用表单验证一会我们POST请求中会携带表单信息
.checkTokenAccess("permitAll()"); //允许所有的Token查询请求
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.authenticationManager(manager);
//由于SpringSecurity新版本的一些底层改动这里需要配置一下authenticationManager才能正常使用password模式
}
}
```
接着我们就可以启动服务器了:
![image-20220410123941157](https://tva1.sinaimg.cn/large/e6c9d24ely1h14iuwapslj220008ejx2.jpg)
然后我们使用Postman进行接口测试首先我们从最简单的客户端模式进行测试客户端模式只需要提供id和secret即可直接拿到Token注意需要再添加一个grant_type来表明我们的授权方式默认请求路径为http://localhost:8500/sso/oauth/token
![image-20220410125437766](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jafpizcj21cw0eg76a.jpg)
发起请求后可以看到我们得到了Token它是以JSON格式给到我们的
![image-20220410125831300](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jehayojj21cw0am0ty.jpg)
我们还可以访问 http://localhost:8500/sso/oauth/check_token 来验证我们的Token是否有效
![image-20220410130204734](https://tva1.sinaimg.cn/large/e6c9d24ely1h14ji6rg42j21bk0cgmym.jpg)
![image-20220410130223312](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jiieikmj21bi0ew75d.jpg)
可以看到active为true表示我们刚刚申请到的Token是有效的。
接着我们来测试一下第二种password模式我们还需要提供具体的用户名和密码授权模式定义为password即可
![image-20220410130958929](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jqelib5j21bg0fg0u9.jpg)
接着我们需要在请求头中添加Basic验证信息这里我们直接填写id和secret即可
![image-20220410130819980](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jooye3nj21b00d4q4n.jpg)
可以看到在请求头中自动生成了Basic验证相关内容
![image-20220410130942662](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jq4t7y9j21a207s751.jpg)
![image-20220410131048992](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jra2ysyj21ay0bgta5.jpg)
响应成功得到Token信息并且这里还多出了一个refresh_token这是用于刷新Token的我们之后会进行讲解。
![image-20220410131641887](https://tva1.sinaimg.cn/large/e6c9d24ely1h14jxemfuxj21d20fcjsq.jpg)
查询Token信息之后还可以看到登录的具体用户以及角色权限等。
接着我们来看隐式授权模式这种模式我们需要在验证服务器上进行登录操作而不是直接请求Token验证登录请求地址http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=token
注意response_type一定要是token类型这样才会直接返回Token浏览器发起请求后可以看到熟悉而又陌生的界面没错实际上这里就是使用我们之前讲解的SpringSecurity进行登陆当然也可以配置一下记住我之类的功能这里就不演示了
![image-20220410132507626](https://tva1.sinaimg.cn/large/e6c9d24ely1h14k666r5sj22aq0js0ue.jpg)
但是登录之后我们发现出现了一个错误:
![image-20220410132557704](https://tva1.sinaimg.cn/large/e6c9d24ely1h14k71g3joj21ae0a60u3.jpg)
这是因为登录成功之后验证服务器需要将结果给回客户端所以需要提供客户端的回调地址这样浏览器就会被重定向到指定的回调地址并且请求中会携带Token信息这里我们随便配置一个回调地址
```java
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
.inMemory()
.withClient("web")
.secret(encoder.encode("654321"))
.autoApprove(false)
.scopes("book", "user", "borrow")
.redirectUris("http://localhost:8201/login") //可以写多个,当有多个时需要在验证请求中指定使用哪个地址进行回调
.authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
}
```
接着重启验证服务器,再次访问:
![image-20220410132927907](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kaonj56j21fw0gw40h.jpg)
可以看到这里会让我们选择哪些范围进行授权,就像我们在微信小程序中登陆一样,会让我们授予用户信息权限、支付权限、信用查询权限等,我们可以自由决定要不要给客户端授予访问这些资源的权限,这里我们全部选择授予:
![image-20220410133106639](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kcehaeej21ye08o758.jpg)
授予之后可以看到浏览器被重定向到我们刚刚指定的回调地址中并且携带了Token信息现在我们来校验一下看看
![image-20220410133210183](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kdhpibjj21d00e675i.jpg)
可以看到Token也是有效的。
最后我们来看看第四种最安全的授权码模式这种模式其实流程和上面是一样的但是请求的是code类型http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=code
可以看到访问之后依然会进入到回调地址但是这时给的就是授权码了而不是直接给Token那么这个Token该怎么获取呢
![image-20220410133411554](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kflls50j2178066glw.jpg)
按照我们之前讲解的原理我们需要携带授权码和secret一起请求才能拿到Token正常情况下是由回调的服务器进行处理这里我们就在Postman中进行我们复制刚刚得到的授权码接口依然是`localhost:8500/sso/oauth/token`
![image-20220410133702534](https://tva1.sinaimg.cn/large/e6c9d24ely1h14kike171j21ba0f8404.jpg)
可以看到结果也是正常返回了Token信息
![image-20220410133940312](https://tva1.sinaimg.cn/large/e6c9d24ely1h14klb6srtj21be0bmgn6.jpg)
这样我们四种最基本的Token请求方式就实现了。
最后还有一个是刷新令牌使用的当我们的Token过期时我们就可以使用这个refresh_token来申请一个新的Token
![image-20220410140759967](https://tva1.sinaimg.cn/large/e6c9d24ely1h14lesf7waj21bc0giq4u.jpg)
但是执行之后我们发现会直接出现一个内部错误:
![image-20220410140822286](https://tva1.sinaimg.cn/large/e6c9d24ely1h14lf5oqckj217804mt8w.jpg)
![image-20220410140838064](https://tva1.sinaimg.cn/large/e6c9d24ely1h14lffhh02j21w601qmy6.jpg)
查看日志发现这里还需要我们单独配置一个UserDetailsService我们直接把Security中的实例注册为Bean
```java
@Bean
@Override
public UserDetailsService userDetailsServiceBean() throws Exception {
return super.userDetailsServiceBean();
}
```
然后在Endpoint中设置
```java
@Resource
UserDetailsService service;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.userDetailsService(service)
.authenticationManager(manager);
}
```
最后再次尝试刷新Token
![image-20220410141143519](https://tva1.sinaimg.cn/large/e6c9d24ely1h14linq3lkj21bc0bumyp.jpg)
OK成功刷新Token返回了一个新的。
### 基于@EnableOAuth2Sso实现
前面我们将验证服务器已经搭建完成了现在我们就来实现一下单点登陆吧SpringCloud为我们提供了客户端的直接实现我们只需要添加一个注解和少量配置即可将我们的服务作为一个单点登陆应用使用的是第四种授权码模式。
一句话来说就是,这种模式只是将验证方式由原本的默认登录形式改变为了统一在授权服务器登陆的形式。
首先还是依赖:
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
```
我们只需要直接在启动类上添加即可:
```java
@EnableOAuth2Sso
@SpringBootApplication
public class BookApplication {
public static void main(String[] args) {
SpringApplication.run(BookApplication.class, args);
}
}
```
我们不需要进行额外的配置类,因为这个注解已经帮我们做了:
```java
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EnableOAuth2Client
@EnableConfigurationProperties({OAuth2SsoProperties.class})
@Import({OAuth2SsoDefaultConfiguration.class, OAuth2SsoCustomConfiguration.class, ResourceServerTokenServicesConfiguration.class})
public @interface EnableOAuth2Sso {
}
```
可以看到它直接注册了OAuth2SsoDefaultConfiguration而这个类就是帮助我们对Security进行配置的
```java
@Configuration
@Conditional({NeedsWebSecurityCondition.class})
public class OAuth2SsoDefaultConfiguration extends WebSecurityConfigurerAdapter {
//直接继承的WebSecurityConfigurerAdapter帮我们把验证设置都写好了
private final ApplicationContext applicationContext;
public OAuth2SsoDefaultConfiguration(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
```
接着我们需要在配置文件中配置我们的验证服务器相关信息:
```yaml
security:
oauth2:
client:
#不多说了
client-id: web
client-secret: 654321
#Token获取地址
access-token-uri: http://localhost:8500/sso/oauth/token
#验证页面地址
user-authorization-uri: http://localhost:8500/sso/oauth/authorize
resource:
#Token信息获取和校验地址
token-info-uri: http://localhost:8500/sso/oauth/check_token
```
现在我们就开启图书服务,调用图书接口:
![image-20220410144654362](https://tva1.sinaimg.cn/large/e6c9d24ely1h14mj9etfuj22730u0q8o.jpg)
可以看到在发现没有登录验证时,会直接跳转到授权页面,进行授权登录,之后才可以继续访问图书服务:
![image-20220410144806506](https://tva1.sinaimg.cn/large/e6c9d24ely1h14mkinbbyj219806mjs1.jpg)
那么用户信息呢是否也一并保存过来了我们这里直接获取一下SpringSecurity的Context查看用户信息获取方式跟我们之前的视频中讲解的是一样的
```java
@RequestMapping("/book/{bid}")
Book findBookById(@PathVariable("bid") int bid){
//通过SecurityContextHolder将用户信息取出
SecurityContext context = SecurityContextHolder.getContext();
System.out.println(context.getAuthentication());
return service.getBookById(bid);
}
```
再次访问图书管理接口,可以看到:
![image-20220410145224153](https://tva1.sinaimg.cn/large/e6c9d24ely1h14moza8zuj220k01adge.jpg)
这里使用的不是之前的UsernamePasswordAuthenticationToken也不是RememberMeAuthenticationToken而是新的OAuth2Authentication它保存了验证服务器的一些信息以及经过我们之前的登陆流程之后验证服务器发放给客户端的Token信息并通过Token信息在验证服务器进行验证获取用户信息最后保存到Session中表示用户已验证所以本质上还是要依赖浏览器存Cookie的。
接下来我们将所有的服务都使用这种方式进行验证,别忘了把重定向地址给所有服务都加上:
```java
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
.inMemory()
.withClient("web")
.secret(encoder.encode("654321"))
.autoApprove(true) //这里把自动审批开了,就不用再去手动选同意了
.scopes("book", "user", "borrow")
.redirectUris("http://localhost:8101/login", "http://localhost:8201/login", "http://localhost:8301/login")
.authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
}
```
这样我们就可以实现只在验证服务器登陆,如果登陆过其他的服务都可以访问了。
但是我们发现一个问题就是由于SESSION不同步每次切换不同的服务进行访问都会重新导验证服务器去验证一次
![image-20220410160648326](https://tva1.sinaimg.cn/large/e6c9d24ely1h14oue66ytj212606wq42.jpg)
这里有两个方案:
* 像之前一样做SESSION统一存储
* 设置context-path路径每个服务单独设置就不会打架了
但是这样依然没法解决服务间调用的问题,所以仅仅依靠单点登陆的模式不太行。
### 基于@EnableResourceServer实现
前面我们讲解了将我们的服务作为单点登陆应用直接实现单点登陆那么现在我们如果是以第三方应用进行访问呢这时我们就需要将我们的服务作为资源服务了作为资源服务就不会再提供验证的过程而是直接要求请求时携带Token而验证过程我们这里就继续用Postman来完成这才是我们常见的模式。
一句话来说跟上面相比我们只需要携带Token就能访问这些资源服务器了客户端被独立了出来用于携带Token去访问这些服务。
我们也只需要添加一个注解和少量配置即可:
```java
@EnableResourceServer
@SpringBootApplication
public class BookApplication {
public static void main(String[] args) {
SpringApplication.run(BookApplication.class, args);
}
}
```
配置中只需要:
```yaml
security:
oauth2:
client:
#基操
client-id: web
client-secret: 654321
resource:
#因为资源服务器得验证你的Token是否有访问此资源的权限以及用户信息所以只需要一个验证地址
token-info-uri: http://localhost:8500/sso/oauth/check_token
```
配置完成后,我们启动服务器,直接访问会发现:
![image-20220411090932561](https://tva1.sinaimg.cn/large/e6c9d24ely1h15iekw720j21mw0b0mz5.jpg)
这是由于我们的请求头中没有携带Token信息现在有两种方式可以访问此资源
* 在URL后面添加`access_token`请求参数值为Token值
* 在请求头中添加`Authorization`,值为`Bearer +Token值`
我们先来试试看最简的一种:
![image-20220411091259795](https://tva1.sinaimg.cn/large/e6c9d24ely1h15ii54emij2176066wfi.jpg)
另一种我们需要使用Postman来完成
![image-20220411091514501](https://tva1.sinaimg.cn/large/e6c9d24ely1h15ikgy886j21c60ikjty.jpg)
添加验证信息后,会帮助我们转换成请求头信息:
![image-20220411091655947](https://tva1.sinaimg.cn/large/e6c9d24ely1h15im8lcwuj21b008cwfo.jpg)
![image-20220411091722652](https://tva1.sinaimg.cn/large/e6c9d24ely1h15imovn8jj216m056aaa.jpg)
这样我们就将资源服务器搭建完成了。
我们接着来看如何对资源服务器进行深度自定义我们可以为其编写一个配置类比如我们现在希望用户授权了某个Scope才可以访问此服务
```java
@Configuration
public class ResourceConfiguration extends ResourceServerConfigurerAdapter { //继承此类进行高度自定义
@Override
public void configure(HttpSecurity http) throws Exception { //这里也有HttpSecurity对象方便我们配置SpringSecurity
http
.authorizeRequests()
.anyRequest().access("#oauth2.hasScope('lbwnb')"); //添加自定义规则
//Token必须要有我们自定义scope授权才可以访问此资源
}
}
```
可以看到当没有对应的scope授权时那么会直接返回`insufficient_scope`错误:
![image-20220411092852367](https://tva1.sinaimg.cn/large/e6c9d24ely1h15iynrm44j21d00acjth.jpg)
不知道各位是否有发现实际上资源服务器完全没有必要将Security的信息保存在Session中了因为现在只需要将Token告诉资源服务器那么资源服务器就可以联系验证服务器得到用户信息就不需要使用之前的Session存储机制了所以你会发现HttpSession中没有**SPRING_SECURITY_CONTEXT**现在Security信息都是通过连接资源服务器获取。
接着我们将所有的服务都
但是还有一个问题没有解决我们在使用RestTemplate进行服务间的远程调用时会得到以下错误
![image-20220411115958560](https://tva1.sinaimg.cn/large/e6c9d24ely1h15nbvmhyqj21zo070n12.jpg)
实际上这是因为在服务调用时没有携带Token信息我们得想个办法把用户传来的Token信息在进行远程调用时也携带上因此我们可以直接使用OAuth2RestTemplate它会在请求其他服务时携带当前请求的Token信息。它继承自RestTemplate这里我们直接定义一个Bean
```java
@Configuration
public class WebConfiguration {
@Resource
OAuth2ClientContext context;
@Bean
public OAuth2RestTemplate restTemplate(){
return new OAuth2RestTemplate(new ClientCredentialsResourceDetails(), context);
}
}
```
接着我们直接替换掉之前的RestTemplate即可
```java
@Service
public class BorrowServiceImpl implements BorrowService {
@Resource
BorrowMapper mapper;
@Resource
OAuth2RestTemplate template;
@Override
public UserBorrowDetail getUserBorrowDetailByUid(int uid) {
List<Borrow> borrow = mapper.getBorrowsByUid(uid);
User user = template.getForObject("http://localhost:8101/user/"+uid, User.class);
//获取每一本书的详细信息
List<Book> bookList = borrow
.stream()
.map(b -> template.getForObject("http://localhost:8201/book/"+b.getBid(), Book.class))
.collect(Collectors.toList());
return new UserBorrowDetail(user, bookList);
}
}
```
可以看到服务成功调用了:
![image-20220411124509007](https://tva1.sinaimg.cn/large/e6c9d24ely1h15omvqe0nj21im06emyf.jpg)
现在我们来将Nacos加入并通过Feign实现远程调用。
依赖还是贴一下,不然找不到:
```xml
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
```
```xml
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
```
所有服务都已经注册成功了:
![image-20220411132756540](https://tva1.sinaimg.cn/large/e6c9d24ely1h15pvei9c9j220w0cgdia.jpg)
接着我们配置一下借阅服务的负载均衡:
```java
@Configuration
public class WebConfiguration {
@Resource
OAuth2ClientContext context;
@LoadBalanced //和RestTemplate一样直接添加注解就行了
@Bean
public OAuth2RestTemplate restTemplate(){
return new OAuth2RestTemplate(new ClientCredentialsResourceDetails(), context);
}
}
```
![image-20220411133819847](https://tva1.sinaimg.cn/large/e6c9d24ely1h15q67k904j21ho06a75k.jpg)
现在我们来把它替换为Feign老样子两个客户端
```java
@FeignClient("user-service")
public interface UserClient {
@RequestMapping("/user/{uid}")
User getUserById(@PathVariable("uid") int uid);
}
```
```java
@FeignClient("book-service")
public interface BookClient {
@RequestMapping("/book/{bid}")
Book getBookById(@PathVariable("bid") int bid);
}
```
但是配置完成之后又出现刚刚的问题了OpenFeign也没有携带Token进行访问
![image-20220411135612728](https://tva1.sinaimg.cn/large/e6c9d24ely1h15qotlr59j21zq06s77b.jpg)
那么怎么配置Feign携带Token访问呢遇到这种问题直接去官方查https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/#oauth2-support非常简单两个配置就搞定
```yaml
feign:
oauth2:
#开启Oauth支持这样就会在请求头中携带Token了
enabled: true
#同时开启负载均衡支持
load-balanced: true
```
重启服务器可以看到结果OK了
![image-20220411143628451](https://tva1.sinaimg.cn/large/e6c9d24ely1h15rupqldmj21ko07kmyl.jpg)
这样我们就成功将之前的三个服务作为资源服务器了注意和我们上面的作为客户端是不同的将服务直接作为客户端相当于只需要验证通过即可并且还是要保存Session信息相当于只是将登录流程换到统一的验证服务器上进行罢了。而将其作为资源服务器那么就需要另外找客户端可以是浏览器、小程序、App、第三方服务等来访问并且也是需要先进行验证然后再通过携带Token进行访问这种模式是我们比较常见的模式。
### 使用jwt存储Token
官网https://jwt.io
JSON Web Token令牌JWT是一个开放标准[RFC 7519](https://tools.ietf.org/html/rfc7519)它定义了一种紧凑和自成一体的方式用于在各方之间作为JSON对象安全地传输信息。这些信息可以被验证和信任因为它是数字签名的。JWT可以使用密钥使用**HMAC**算法)或使用**RSA**或**ECDSA**进行公钥/私钥对进行签名。
实际上我们之前都是携带Token向资源服务器发起请求后资源服务器由于不知道我们Token的用户信息所以需要向验证服务器询问此Token的认证信息这样才能得到Token代表的用户信息但是各位是否考虑过如果每次用户请求都去查询用户信息那么在大量请求下验证服务器的压力可能会非常的大。而使用JWT之后Token中会直接保存用户信息这样资源服务器就不再需要询问验证服务器自行就可以完成解析我们的目标是不联系验证服务器就能直接完成验证。
JWT令牌的格式如下
![image-20220412083957167](https://tva1.sinaimg.cn/large/e6c9d24ely1h16n64387aj22b10u0wjb.jpg)
一个JWT令牌由3部分组成标头(Header)、有效载荷(Payload)和签名(Signature)。在传输的时候会将JWT的3部分分别进行Base64编码后用`.`进行连接形成最终需要传输的字符串。
* 标头包含一些元数据信息比如JWT签名所使用的加密算法还有类型这里统一都是JWT。
* 有效载荷包括用户名称、令牌发布时间、过期时间、JWT ID等当然我们也可以自定义添加字段我们的用户信息一般都在这里存放。
* 签名首先需要指定一个密钥该密钥仅仅保存在服务器中保证不能让其他用户知道。然后使用Header中指定的算法对Header和Payload进行base64加密之后的结果通过密钥计算哈希值然后就得出一个签名哈希。这个会用于之后验证内容是否被篡改。
这里还是补充一下一些概念,因为很多东西都是我们之前没有接触过的:
* **Base64**就是包括小写字母a-z、大写字母A-Z、数字0-9、符号"+"、"/"一共64个字符的字符集末尾还有1个或多个`=`用来凑够字节数任何的符号都可以转换成这个字符集中的字符这个转换过程就叫做Base64编码编码之后会生成只包含上述64个字符的字符串。相反如果需要原本的内容我们也可以进行Base64解码回到原有的样子。
```java
public void test(){
String str = "你们可能不知道只用20万赢到578万是什么概念";
//Base64不只是可以对字符串进行编码任何byte[]数据都可以编码结果可以是byte[],也可以是字符串
String encodeStr = Base64.getEncoder().encodeToString(str.getBytes());
System.out.println("Base64编码后的字符串"+encodeStr);
System.out.println("解码后的字符串:"+new String(Base64.getDecoder().decode(encodeStr)));
}
```
注意Base64不是加密算法只是一种信息的编码方式而已。
* **加密算法:**加密算法分为对称加密和非对称加密,其中**对称加密Symmetric Cryptography**比较好理解,就像一把锁配了两把钥匙一样,这两把钥匙你和别人都有一把,然后你们直接传递数据,都会把数据用锁给锁上,就算传递的途中有人把数据窃取了,也没办法解密,因为钥匙只有你和对方有,没有钥匙无法进行解密,但是这样有个问题,既然解密的关键在于钥匙本身,那么如果有人不仅窃取了数据,而且对方那边的治安也不好,于是顺手就偷走了钥匙,那你们之间发的数据不就凉凉了吗。
因此,**非对称加密Asymmetric Cryptography**算法出现了,它并不是直接生成一把钥匙,而是生成一个公钥和一个私钥,私钥只能由你保管,而公钥交给对方或是你要发送的任何人都行,现在你需要把数据传给对方,那么就需要使用私钥进行加密,但是,这个数据只能使用对应的公钥进行解密,相反,如果对方需要给你发送数据,那么就需要用公钥进行加密,而数据只能使用私钥进行解密,这样的话就算对方的公钥被窃取,那么别人发给你的数据也没办法解密出来,因为需要私钥才能解密,而只有你才有私钥。
因此非对称加密的安全性会更高一些包括HTTPS的隐私信息正是使用非对称加密来保障传输数据的安全当然HTTPS并不是单纯地使用非对称加密完成的感兴趣的可以去了解一下
对称加密和非对称加密都有很多的算法比如对称加密就有DES、IDEA、RC2非对称加密有RSA、DAS、ECC
* **不可逆加密算法:**常见的不可逆加密算法有MD5, HMAC, SHA-1, SHA-224, SHA-256, SHA-384, 和SHA-512, 其中SHA-224、SHA-256、SHA-384和SHA-512我们可以统称为SHA2加密算法SHA加密算法的安全性要比MD5更高而SHA2加密算法比SHA1的要高其中SHA后面的数字表示的是加密后的字符串长度SHA1默认会产生一个160位的信息摘要。经过不可逆加密算法得到的加密结果是无法解密回去的也就是说加密出来是什么就是什么了。本质上其就是一种哈希函数用于对一段信息产生摘要以**防止被篡改**。
实际上这种算法就常常被用作信息摘要计算,同样的数据通过同样的算法计算得到的结果肯定也一样,而如果数据被修改,那么计算的结果肯定就不一样了。
这里我们就可以利用jwt将我们的Token采用新的方式进行存储
![image-20220412113124880](https://tva1.sinaimg.cn/large/e6c9d24ely1h16s4h08b9j21ku0dumz4.jpg)
这里我们使用最简单的一种方式,对称密钥,我们需要对验证服务器进行一些修改:
```java
@Bean
public JwtAccessTokenConverter tokenConverter(){ //Token转换器将其转换为JWT
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("lbwnb"); //这个是对称密钥,一会资源服务器那边也要指定为这个
return converter;
}
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter converter){ //Token存储方式现在改为JWT存储
return new JwtTokenStore(converter); //传入刚刚定义好的转换器
}
```
```java
@Resource
TokenStore store;
@Resource
JwtAccessTokenConverter converter;
private AuthorizationServerTokenServices serverTokenServices(){ //这里对AuthorizationServerTokenServices进行一下配置
DefaultTokenServices services = new DefaultTokenServices();
services.setSupportRefreshToken(true); //允许Token刷新
services.setTokenStore(store); //添加刚刚的TokenStore
services.setTokenEnhancer(converter); //添加Token增强其实就是JwtAccessTokenConverter增强是添加一些自定义的数据到JWT中
return services;
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.tokenServices(serverTokenServices()) //设定为刚刚配置好的AuthorizationServerTokenServices
.userDetailsService(service)
.authenticationManager(manager);
}
```
然后我们就可以重启验证服务器了:
![image-20220412115919019](https://tva1.sinaimg.cn/large/e6c9d24ely1h16sxi8zk7j21c20dkdjy.jpg)
可以看到成功获取了AccessToken但是这里的格式跟我们之前的格式就大不相同了因为现在它是JWT令牌我们可以对其进行一下Base64解码
![image-20220412120136453](https://tva1.sinaimg.cn/large/e6c9d24ely1h16szvyek8j22om0dsmzi.jpg)
可以看到所有的验证信息包含在内,现在我们对资源服务器进行配置:
```yaml
security:
oauth2:
resource:
jwt:
key-value: lbwnb #注意这里要跟验证服务器的密钥一致,这样算出来的签名才会一致
```
然后启动资源服务器,请求一下接口试试看:
![image-20220412120350331](https://tva1.sinaimg.cn/large/e6c9d24ely1h16t27q8g1j21b60foacd.jpg)
请求成功,得到数据:
![image-20220412120417005](https://tva1.sinaimg.cn/large/e6c9d24ely1h16t2o2neej21ca08074t.jpg)
注意如果Token有误那么会得到
![image-20220412120441794](https://tva1.sinaimg.cn/large/e6c9d24ely1h16t33dv9kj215m04uaab.jpg)
***
## Redis与分布式
在SpringBoot阶段我们学习了Redis它是一个基于内存的高性能数据库我们当时已经学习了包括基本操作、常用数据类型、持久化、事务和锁机制以及使用Java与Redis进行交互等利用它的高性能我们还使用它来做Mybatis的二级缓存、以及Token的持久化存储。而这一部分我们将继续深入探讨Redis在分布式开发场景下的应用。
### 主从复制
在分布式场景下我们可以考虑让Redis实现主从模式
![image-20220412161901616](https://tva1.sinaimg.cn/large/e6c9d24ely1h170fqh9k0j21ao07gwf9.jpg)
主从复制是指将一台Redis服务器的数据复制到其他的Redis服务器。前者称为主节点(Master),后者称为从节点(Slave)数据的复制是单向的只能由主节点到从节点。Master以写为主Slave 以读为主。
这样的好处肯定是显而易见的:
* 实现了读写分离,提高了性能。
* 在写少读多的场景下,我们甚至可以安排很多个从节点,这样就能够大幅度的分担压力,并且就算挂掉一个,其他的也能使用。
那么我们现在就来尝试实现一下这里我们还是在Windows下进行测试打开Redis文件夹我们要开启两个Redis服务器修改配置文件`redis.windows.conf`
```conf
# Accept connections on the specified port, default is 6379 (IANA #815344).
# If port 0 is specified Redis will not listen on a TCP socket.
port 6001
```
一个服务器的端口设定为6001复制一份另一个的端口为6002接着我们指定配置文件进行启动打开cmd
![image-20220413115227344](https://tva1.sinaimg.cn/large/e6c9d24ely1h17ycpqc7aj21ho0iytcq.jpg)
现在我们的两个服务器就启动成功了,接着我们可以使用命令查看当前服务器的主从状态,我们打开客户端:
![image-20220413115458597](https://tva1.sinaimg.cn/large/e6c9d24ely1h17yfaen55j21ge0d6dj4.jpg)
输入`info replication`命令来查看当前的主从状态可以看到默认的角色为master也就是说所有的服务器在启动之后都是主节点的状态。那么现在我们希望让6002作为从节点通过一个命令即可
![image-20220413115722765](https://tva1.sinaimg.cn/large/e6c9d24ely1h17yhstlr6j21hk0k078p.jpg)
可以看到,在输入`replicaof 127.0.0.1 6001`命令后就会将6001服务器作为主节点而当前节点作为6001的从节点并且角色也会变成slave接着我们来看看6001的情况
![image-20220413115959759](https://tva1.sinaimg.cn/large/e6c9d24ely1h17ykiln90j21cq0c4tbw.jpg)
可以看到从节点信息中已经出现了6002服务器也就是说现在我们的6001和6002就形成了主从关系还包含一个偏移量这个偏移量反应的是从节点的同步情况
> 主服务器和从服务器都会维护一个复制偏移量,主服务器每次向从服务器中传递 N 个字节的时候,会将自己的复制偏移量加上 N。从服务器中收到主服务器的 N 个字节的数据,就会将自己额复制偏移量加上 N通过主从服务器的偏移量对比可以很清楚的知道主从服务器的数据是否处于一致如果不一致就需要进行增量同步了。
那么我们现在可以来测试一下,在主节点新增数据,看看是否会同步到从节点:
![image-20220413120133934](https://tva1.sinaimg.cn/large/e6c9d24ely1h17ym5549oj219w06kjs4.jpg)
可以看到我们在6001服务器插入的`a`可以在从节点6002读取到那么从节点新增的数据在主节点能得到吗我们来测试一下
![image-20220413120235138](https://tva1.sinaimg.cn/large/e6c9d24ely1h17yn7gu6zj20xy0323z1.jpg)
可以看到从节点压根就没办法进行数据插入节点的模式为只读模式。那么如果我们现在不想让6002作为6001的从节点了呢
![image-20220413124021549](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zqijbfmj21bu09s76d.jpg)
可以看到,通过输入`replicaof no one`即可变回Master角色。接着我们再来启动一台6003服务器流程是一样的
![image-20220413121338391](https://tva1.sinaimg.cn/large/e6c9d24ely1h17yypexpij215c062gmg.jpg)
可以看到,在连接之后,也会直接同步主节点的数据,因此无论是已经处于从节点状态还是刚刚启动完成的服务器,都会从主节点同步数据,实际上整个同步流程为:
1. 从节点执行replicaof ip port命令后从节点会保存主节点相关的地址信息。
2. 从节点通过每秒运行的定时任务发现配置了新的主节点后,会尝试与该节点建立网络连接,专门用于接收主节点发送的复制命令。
3. 连接成功后,第一次会将主节点的数据进行全量复制,之后采用增量复制,持续将新来的写命令同步给从节点。
当我们的主节点关闭后,从节点依然可以读取数据:
![image-20220413122411006](https://tva1.sinaimg.cn/large/e6c9d24ely1h17z9oo7bdj21ce0majw5.jpg)
但是从节点会疯狂报错:
![image-20220413122528096](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zb0v187j21he032myo.jpg)
当然每次都去敲个命令配置主从太麻烦了,我们可以直接在配置文件中配置,添加这样行即可:
```
replicaof 127.0.0.1 6001
```
这里我们给6002和6003服务器都配置一下现在我们重启三个服务器。
![image-20220413122848685](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zehyh4pj21hg0d6gu1.jpg)
当然除了作为Master节点的从节点外我们还可以将其作为从节点的从节点比如现在我们让6003作为6002的从节点
![image-20220413123047711](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zgkb251j216e09mq4n.jpg)
也就是说,现在差不多是这样的的一个情况:
![image-20220413123603650](https://tva1.sinaimg.cn/large/e6c9d24ely1h17zm1h2wpj21ja0ikjv0.jpg)
采用这种方式,优点肯定是显而易见的,但是缺点也很明显,整个传播链路一旦中途出现问题,那么就会导致后面的从节点无法及时同步。
### 哨兵模式
前面我们讲解了Redis实现主从复制的一些基本操作那么我们接着来看哨兵模式。
经过之前的学习我们发现实际上最关键的还是主节点因为一旦主节点出现问题那么整个主从系统将无法写入因此我们得想一个办法处理一下主节点故障的情况。实际上我们可以参考之前的服务治理模式比如Nacos和Eureka所有的服务都会被实时监控那么只要出现问题肯定是可以及时发现的并且能够采取响应的补救措施这就是我们即将介绍的哨兵
![image-20220413154102800](https://tva1.sinaimg.cn/large/e6c9d24ely1h184yisdi6j218i0dk405.jpg)
注意这里的哨兵不是我们之前学习SpringCloud Alibaba的那个是专用于Redis的。哨兵会对所有的节点进行监控如果发现主节点出现问题那么会立即让从节点进行投票选举一个新的主节点出来这样就不会由于主节点的故障导致整个系统不可写注意要实现这样的功能最小的系统必须是一主一从再小的话就没有意义了
![image-20220413155459399](https://tva1.sinaimg.cn/large/e6c9d24ely1h185d16octj21940dawg5.jpg)
那么怎么启动一个哨兵呢?我们只需要稍微修改一下配置文件即可,这里直接删除全部内容,添加:
```
sentinel monitor lbwnb 127.0.0.1 6001 1
```
其中第一个和第二个是固定第三个是为监控对象名称随意后面就是主节点的相关信息包括IP地址和端口最后一个1我们暂时先不说然后我们使用此配置文件启动服务器可以看到启动后
![image-20220413161154185](https://tva1.sinaimg.cn/large/e6c9d24ely1h185umqg2rj21fg0maq8l.jpg)
![image-20220413161306103](https://tva1.sinaimg.cn/large/e6c9d24ely1h185vv5uuvj217e03u40p.jpg)
可以看到以哨兵模式启动后,会自动监控主节点,然后还会显示那些节点是作为从节点存在的。
现在我们直接把主节点关闭,看看会发生什么事情:
![image-20220413161730035](https://tva1.sinaimg.cn/large/e6c9d24ely1h1860g13toj21hg072n1k.jpg)
可以看到从节点还是正常的在报错,一开始的时候不会直接重新进行选举而是继续尝试重连(因为有可能只是网络小卡一下,没必要这么敏感),但是我们发现,经过一段时间之后,依然无法连接,哨兵输出了以下内容:
![image-20220413161843439](https://tva1.sinaimg.cn/large/e6c9d24ely1h1861pyd5uj21gs0jman3.jpg)
可以看到哨兵发现主节点已经有一段时间不可用了那么就会开始进行重新选举6003节点被选为了新的主节点并且之前的主节点6001变成了新的主节点的从节点
![image-20220413162259056](https://tva1.sinaimg.cn/large/e6c9d24ely1h18665ksokj21hq05sjuu.jpg)
![image-20220413162310821](https://tva1.sinaimg.cn/large/e6c9d24ely1h1866crze4j21f207kq5f.jpg)
当我们再次启动6001时会发现它自动变成了6003的从节点并且会将数据同步过来
![image-20220413163527235](https://tva1.sinaimg.cn/large/e6c9d24ely1h186j4ul8mj21gu09qq8y.jpg)
那么,这个选举规则是怎样的呢?是在所有的从节点中随机选取还是遵循某种规则呢?
1. 首先会根据优先级进行选择,可以在配置文件中进行配置,添加`replica-priority`配置项默认是100越小表示优先级越高。
2. 如果优先级一样,那就选择偏移量最大的
3. 要是还选不出来那就选择runid启动时随机生成的最小的。
要是哨兵也挂了咋办没事咱们可以多安排几个哨兵只需要把哨兵的配置复制一下然后修改端口这样就可以同时启动多个哨兵了我们启动3个哨兵一主二从三哨兵这里我们吧最后一个值改为`2`
```
sentinel monitor lbwnb 192.168.0.8 6001 2
```
这个值实际上代表的是当有几个哨兵认为主节点挂掉时,就判断主节点真的挂掉了
![image-20220413201201051](https://tva1.sinaimg.cn/large/e6c9d24ely1h18csiah4ej21e60b8794.jpg)
现在我们把6001节点挂掉看看这三个哨兵会怎么样
![image-20220413203351360](https://tva1.sinaimg.cn/large/e6c9d24ely1h18df6teykj21h60fk7d0.jpg)
可以看到都显示将master切换为6002节点了。
那么在哨兵重新选举新的主节点之后我们Java中的Redis的客户端怎么感知到呢我们来看看首先还是导入依赖
```xml
<dependencies>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.2.1</version>
</dependency>
</dependencies>
```
```java
public class Main {
public static void main(String[] args) {
//这里我们直接使用JedisSentinelPool来获取Master节点
//需要把三个哨兵的地址都填入
try (JedisSentinelPool pool = new JedisSentinelPool("lbwnb",
new HashSet<>(Arrays.asList("192.168.0.8:26741", "192.168.0.8:26740", "192.168.0.8:26739")))) {
Jedis jedis = pool.getResource(); //直接询问并得到Jedis对象这就是连接的Master节点
jedis.set("test", "114514"); //直接写入即可实际上就是向Master节点写入
Jedis jedis2 = pool.getResource(); //再次获取
System.out.println(jedis2.get("test")); //读取操作
} catch (Exception e) {
e.printStackTrace();
}
}
}
```
这样Jedis对象就可以通过哨兵来获取当Master节点更新后也能得到最新的。
### 集群搭建
如果我们服务器的内存不够用了但是现在我们的Redis又需要继续存储内容那么这个时候就可以利用集群来实现扩容。
因为单机的内存容量最大就那么多已经没办法再继续扩展了但是现在又需要存储更多的内容这时我们就可以让N台机器上的Redis来分别存储各个部分的数据每个Redis可以存储1/N的数据量这样就实现了容量的横向扩展。同时每台Redis还可以配一个从节点这样就可以更好地保证数据的安全性。
![image-20220413211725149](https://tva1.sinaimg.cn/large/e6c9d24ely1h18eoii8ttj21c60homz7.jpg)
那么问题来,现在用户来了一个写入的请求,数据该写到哪个节点上呢?我们来研究一下集群的机制:
首先一个Redis集群包含16384个插槽集群中的每个Redis 实例负责维护一部分插槽以及插槽所映射的键值数据,那么这个插槽是什么意思呢?
实际上插槽就是键的Hash计算后的一个结果注意这里出现了`计算机网络`中的CRC循环冗余校验这里采用CRC16能得到16个bit位的数据也就是说算出来之后结果是0-65535之间再进行取模得到最终结果
**Redis key的路由计算公式slot = CRC16key % 16384**
结果的值是多少就应该存放到对应维护的Redis下比如Redis节点1负责0-25565的插槽而这时客户端插入了一个新的数据`a=10`a在Hash计算后结果为666那么a就应该存放到1号Redis节点中。简而言之本质上就是通过哈希算法将插入的数据分摊到各个节点的所以说哈希算法真的是处处都有用啊。
那么现在我们就来搭建一个简单的Redis集群这里创建6个配置注意开启集群模式
```
# Normal Redis instances can't be part of a Redis Cluster; only nodes that are
# started as cluster nodes can. In order to start a Redis instance as a
# cluster node enable the cluster support uncommenting the following:
#
cluster-enabled yes
```
接着记得把所有的持久化文件全部删除,所有的节点内容必须是空的。
然后输入`redis-cli.exe --cluster create --cluster-replicas 1 127.0.0.1:6001 127.0.0.1:6002 127.0.0.1:6003 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003`,这里的`--cluster-replicas 1`指的是每个节点配一个从节点:
![image-20220413215928157](https://tva1.sinaimg.cn/large/e6c9d24ely1h18fw9o0isj21hc0mw12l.jpg)
输入之后会为你展示客户端默认分配的方案并且会询问你当前的方案是否合理。可以看到6001/6002/6003都被选为主节点其他的为从节点我们直接输入yes即可
![image-20220413215243309](https://tva1.sinaimg.cn/large/e6c9d24ely1h18fp8r1m9j21h605sabp.jpg)
最后分配成功,可以看到插槽的分配情况:
![image-20220413215958201](https://tva1.sinaimg.cn/large/e6c9d24ely1h18fwsc8ecj21go0li467.jpg)
现在我们随便连接一个节点,尝试插入一个值:
![image-20220413220609800](https://tva1.sinaimg.cn/large/e6c9d24ely1h18g381vqlj2122030dgk.jpg)
在插入时出现了一个错误实际上这就是因为a计算出来的哈希值插槽不归当前节点管我们得去管这个插槽的节点执行通过上面的分配情况我们可以得到15495属于节点6003管理
![image-20220413220811536](https://tva1.sinaimg.cn/large/e6c9d24ely1h18g5ccyqpj20y205e3z8.jpg)
在6003节点插入成功当然我们也可以使用集群方式连接这样我们无论在哪个节点都可以插入只需要添加`-c`表示以集群模式访问:
![image-20220413220935678](https://tva1.sinaimg.cn/large/e6c9d24ely1h18g6stccaj216y04uwfk.jpg)
可以看到在6001节点成功对a的值进行了更新只不过还是被重定向到了6003节点进行插入。
我们可以输入`cluster nodes`命令来查看当前所有节点的信息:
![image-20220413222104845](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gir6s5mj21hk09mgs0.jpg)
那么现在如果我们让某一个主节点挂掉会怎么样现在我们把6001挂掉
![image-20220413223237962](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gurtemjj21h808wwkx.jpg)
可以看到原本的6001从节点7001晋升为了新的主节点而之前的6001已经挂了现在我们将6001重启试试看
![image-20220413223337494](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gvstwbkj21hk04e76h.jpg)
可以看到6001变成了7001的从节点那么要是6001和7001都挂了呢
![image-20220413223702884](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gzd1jxzj21ha0460v0.jpg)
这时我们尝试插入新的数据:
![image-20220413223724440](https://tva1.sinaimg.cn/large/e6c9d24ely1h18gzqibkqj20va03a74o.jpg)
可以看到当存在节点不可用时会无法插入新的数据现在我们将6001和7001恢复
![image-20220413223813370](https://tva1.sinaimg.cn/large/e6c9d24ely1h18h0l5pcnj21gs07a77z.jpg)
可以看到恢复之后又可以继续正常使用了。
最后我们来看一下如何使用Java连接到集群模式下的Redis我们需要用到JedisCluster对象
```java
public class Main {
public static void main(String[] args) {
//和客户端一样,随便连一个就行,也可以多写几个,构造方法有很多种可以选择
try(JedisCluster cluster = new JedisCluster(new HostAndPort("192.168.0.8", 6003))){
System.out.println("集群实例数量:"+cluster.getClusterNodes().size());
cluster.set("a", "yyds");
System.out.println(cluster.get("a"));
}
}
}
```
操作基本和Jedis对象一样这里就不多做赘述了。
### 分布式锁
在我们的传统单体应用中经常会用到锁机制目的是为了防止多线程竞争导致的并发问题但是现在我们在分布式环境下又该如何实现锁机制呢可能一条链路上有很多的应用它们都是独立运行的这时我们就可以借助Redis来实现分布式锁。
还记得我们上一章最后提出的问题吗?
```java
@Override
public boolean doBorrow(int uid, int bid) {
//1. 判断图书和用户是否都支持借阅如果此时来了10个线程都进来了那么都能够判断为可以借阅
if(bookClient.bookRemain(bid) < 1)
throw new RuntimeException("图书数量不足");
if(userClient.userRemain(uid) < 1)
throw new RuntimeException("用户借阅量不足");
//2. 首先将图书的数量-1由于上面10个线程同时进来同时判断可以借阅那么这个10个线程就同时将图书数量-1那库存岂不是直接变成负数了
if(!bookClient.bookBorrow(bid))
throw new RuntimeException("在借阅图书时出现错误!");
...
}
```
实际上在高并发下我们看似正常的借阅流程会出现问题比如现在同时来了10个同学要借同一本书但是现在只有3本而我们的判断规则是首先看书够不够如果此时这10个请求都已经走到这里并且都判定为可以进行借阅那么问题就出现了接下来这10个请求都开始进行借阅操作导致库存直接爆表形成超借问题在电商系统中也存在同样的超卖问题
因此为了解决这种问题我们就可以利用分布式锁来实现。那么Redis如何去实现分布式锁呢
在Redis存在这样一个命令
```
setnx key value
```
这个命令看起来和`set`命令差不多但是它有一个机制就是只有当指定的key不存在的时候才能进行插入实际上就是`set if not exists`的缩写。
![image-20220414105646460](https://tva1.sinaimg.cn/large/e6c9d24ely1h192d1jdakj214q07uab5.jpg)
可以看到当客户端1设定a之后客户端2使用`setnx`会直接失败。
![image-20220414105854959](https://tva1.sinaimg.cn/large/e6c9d24ely1h192f9yztfj211g08i75i.jpg)
当客户端1将a删除之后客户端2就可以使用`setnx`成功插入了。
利用这种特性,我们就可以在不同的服务中实现分布式锁,那么问题来了,要是某个服务加了锁但是卡顿了呢,或是直接崩溃了,那这把锁岂不是永远无法释放了?因此我们还可以考虑加个过期时间:
```
set a 666 EX 5 NX
```
这里使用`set`命令最后加一个NX表示是使用`setnx`的模式和上面是一样的但是可以通过EX设定过期时间这里设置为5秒也就是说如果5秒还没释放那么就自动删除。
![image-20220414111008456](https://tva1.sinaimg.cn/large/e6c9d24ely1h192qy0x9pj213w09ymyq.jpg)
当然,添加了过期时间,带了的好处是显而易见的,但是同时也带来了很多的麻烦,我们来设想一下这种情况:
![image-20220414112359738](https://tva1.sinaimg.cn/large/e6c9d24ely1h1935d9f0jj21im0cydi8.jpg)
因此单纯只是添加过期时间会出现这种把别人加的锁谁卸了的情况要解决这种问题也很简单我们现在的目标就是保证任务只能删除自己加的锁如果是别人加的锁是没有资格删的所以我们可以吧a的值指定为我们任务专属的值比如可以使用UUID之类的如果在主动删除锁的时候发现值不是我们当前任务指定的那么说明可能是因为超时其他任务已经加锁了。
![image-20220414113041835](https://tva1.sinaimg.cn/large/e6c9d24ely1h193cc7opzj21hy0da40t.jpg)
如果你在学习本篇之前完成了JUC并发编程篇的学习那么一定会有一个疑惑如果在超时之前那一刹那进入到释放锁的阶段获取到值肯定还是自己但是在即将执行删除之前由于超时机制导致被删除并且其他任务也加锁了那么这时再进行删除仍然会导致删除其他任务加的锁。
![image-20220414113709773](https://tva1.sinaimg.cn/large/e6c9d24ely1h193j2b68fj21mw0d6mzv.jpg)
实际上本质还是因为锁的超时时间不太好衡量,如果超时时间能够设定地比较恰当,那么就可以避免这种问题了。
要解决这个问题我们可以借助一下Redisson框架它是Redis官方推荐的Java版的Redis客户端。它提供的功能非常多也非常强大Redisson内部提供了一个监控锁的看门狗它的作用是在Redisson实例被关闭前不断的延长锁的有效期它为我们提供了很多种分布式锁的实现使用起来也类似我们在JUC中学习的锁这里我们尝试使用一下它的分布式锁功能。
```xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.0</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.75.Final</version>
</dependency>
```
首先我们来看看不加锁的情况下:
```java
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try(Jedis jedis = new Jedis("192.168.0.10", 6379)){
for (int j = 0; j < 100; j++) { //每个客户端获取a然后增加a的值再写回去如果不加锁那么肯定会出问题
int a = Integer.parseInt(jedis.get("a")) + 1;
jedis.set("a", a+"");
}
}
}).start();
}
}
```
这里没有直接用`incr`而是我们自己进行计算方便模拟可以看到运行结束之后a的值并不是我们想要的
![image-20220414133258227](https://tva1.sinaimg.cn/large/e6c9d24ely1h196vkaykrj21ce022dft.jpg)
现在我们来给它加一把锁注意这个锁是基于Redis的不仅仅只可以用于当前应用是能够垮系统的
```java
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.0.10:6379"); //配置连接的Redis服务器也可以指定集群
RedissonClient client = Redisson.create(config); //创建RedissonClient客户端
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try(Jedis jedis = new Jedis("192.168.0.10", 6379)){
RLock lock = client.getLock("testLock"); //指定锁的名称,拿到锁对象
for (int j = 0; j < 100; j++) {
lock.lock(); //加锁
int a = Integer.parseInt(jedis.get("a")) + 1;
jedis.set("a", a+"");
lock.unlock(); //解锁
}
}
System.out.println("结束!");
}).start();
}
}
```
可以看到结果没有问题:
![image-20220414133403403](https://tva1.sinaimg.cn/large/e6c9d24ely1h196wp13naj215202caa3.jpg)
注意如果用于存放锁的Redis服务器挂了那么肯定是会出问题的这个时候我们就可以使用RedLock它的思路是在多个Redis服务器上保存锁只需要超过半数的Redis服务器获取到锁那么就真的获取到锁了这样就算挂掉一部分节点也能保证正常运行这里就不做演示了。
***
## MySQL与分布式
前面我讲解了Redis在分布式场景的下的相关应用接着我们来看看MySQL数据库在分布式场景下的应用。
### 主从复制
当我们使用MySQL的时候也可以采取主从复制的策略它的实现思路基本和Redis相似也是采用增量复制的方式MySQL会在运行的过程中会记录二进制日志所有的DML和DDL操作都会被记录进日志中主库只需要将记录的操作复制给从库让从库也运行一次那么就可以实现主从复制。但是注意它不会在一开始进行全量复制所以最好再开始主从之前将数据库的内容保持一致。
和之前一样一旦我们实现了主从复制那么就算主库出现故障从库也能正常提供服务并且还可以实现读写分离等操作。这里我们就使用两台主机来搭建一主一从的环境首先确保两台服务器都安装了MySQL数据库并且都已经正常运行了
![image-20220414162319865](https://tva1.sinaimg.cn/large/e6c9d24ely1h19bsu4pv1j21zq0dyn3r.jpg)
接着我们需要创建对应的账号,一会方便从库进行访问的用户:
```sql
CREATE USER test identified with mysql_native_password by '123456';
```
接着我们开启一下外网访问:
```sh
sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf
```
修改配置文件:
```properties
# If MySQL is running as a replication slave, this should be
# changed. Ref https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_tmpdir
# tmpdir = /tmp
#
# Instead of skip-networking the default is now to listen only on
# localhost which is more compatible and is not less secure.
# bind-address = 127.0.0.1 这里注释掉就行
```
现在我们重启一下MySQL服务
```sh
sudo systemctl restart mysql.service
```
现在我们首先来配置主库,主库只需要为我们刚刚创建好的用户分配一个主从复制的权限即可:
```sql
grant replication slave on *.* to test;
FLUSH PRIVILEGES;
```
然后我们可以输入命令来查看主库的相关情况:
![image-20220414164943974](https://tva1.sinaimg.cn/large/e6c9d24ely1h19ckapbrsj218206s3zi.jpg)
这样主库就搭建完成了,接着我们需要将从库进行配置,首先是配置文件:
```properties
# The following can be used as easy to replay backup logs or for replication.
# note: if you are setting up a replication slave, see README.Debian about
# other settings you may need to change.
# 这里需要将server-id配置为其他的值默认是1所有Mysql主从实例的id必须唯一不能打架不然一会开启会失败
server-id = 2
```
进入数据库,输入:
```sql
change replication source to SOURCE_HOST='192.168.0.8',SOURCE_USER='test',SOURCE_PASSWORD='123456',SOURCE_LOG_FILE='binlog.000004',SOURCE_LOG_POS=591;
```
注意后面的logfile和pos就是我们上面从主库中显示的信息。
![image-20220414170022303](https://tva1.sinaimg.cn/large/e6c9d24ely1h19cvd6eypj218m032my0.jpg)
执行完成后显示OK表示没有问题接着输入
```sql
start replica;
```
现在我们的从机就正式启动了,现在我们输入:
```sql
show replica status\G;
```
来查看当前从机状态,可以看到:
![image-20220414192045320](https://tva1.sinaimg.cn/large/e6c9d24ely1h19gxft4iwj217i0d4jtm.jpg)
最关键的是下面的Replica_IO_Running和Replica_SQL_Running必须同时为Yes才可以实际上从库会创建两个线程一个线程负责与主库进行通信获取二进制日志暂时存放到一个中间表Relay_Log而另一个线程则是将中间表保存的二进制日志的信息进行执行然后插入到从库中。
最后配置完成,我们来看看在主库进行操作会不会同步到从库:
![image-20220414192508849](https://tva1.sinaimg.cn/large/e6c9d24ely1h19h202nr3j21e00aimyl.jpg)
可以看到在主库中创建的数据库,被同步到从库中了,我们再来试试看创建表和插入数据:
```sql
use yyds;
create table test (
`id` int primary key,
`name` varchar(255) NULL,
`passwd` varchar(255) NULL
);
```
![image-20220414192829536](https://tva1.sinaimg.cn/large/e6c9d24ely1h19h5hpocfj21dg0e8tbe.jpg)
现在我们随便插入一点数据:
![image-20220414192920277](https://tva1.sinaimg.cn/large/e6c9d24ely1h19h6cxnxzj215c08s3zn.jpg)
这样我们的MySQL主从就搭建完成了那么如果主机此时挂了会怎么样
![image-20220414200140191](https://tva1.sinaimg.cn/large/e6c9d24ely1h19i41l2flj211k03oq3b.jpg)
可以看到IO线程是处于重连状态会等待主库重新恢复运行。
### 分库分表
在大型的互联网系统中可能单台MySQL的存储容量无法满足业务的需求这时候就需要进行扩容了。
和之前的问题一样,单台主机的硬件资源是存在瓶颈的,不可能无限制地纵向扩展,这时我们就得通过多台实例来进行容量的横向扩容,我们可以将数据分散存储,让多台主机共同来保存数据。
那么问题来了,怎么个分散法?
* **垂直拆分:**我们的表和数据库都可以进行垂直拆分,所谓垂直拆分,就是将数据库中所有的表,按照业务功能拆分到各个数据库中(是不是感觉跟前面两章的学习的架构对应起来了)而对于一张表,也可以通过外键之类的机制,将其拆分为多个表。
![image-20220414204703883](https://tva1.sinaimg.cn/large/e6c9d24ely1h19jf8adocj21e60gyacl.jpg)
* **水平拆分:**水平拆分针对的不是表,而是数据,我们可以让很多个具有相同表的数据库存放一部分数据,相当于是将数据分散存储在各个节点上。
![image-20220414205222383](https://tva1.sinaimg.cn/large/e6c9d24ely1h19jkrb2pkj21g80eu76c.jpg)
那么要实现这样的拆分操作我们自行去编写代码工作量肯定是比较大的因此目前实际上已经有一些解决方案了比如我们可以使用MyCat也是一个数据库中间件相当于挂了一层代理再通过MyCat进行分库分表操作数据库只需要连接就能使用类似的还有ShardingSphere-Proxy或是Sharding JDBC应用程序中直接对SQL语句进行分析然后转换成分库分表操作需要我们自己编写一些逻辑代码这里我们就讲解一下Sharding JDBC。
### Sharding JDBC
![image-20220414214856875](https://tva1.sinaimg.cn/large/e6c9d24ely1h19l7msxooj21ps0iego7.jpg)
**官方文档(中文):**https://shardingsphere.apache.org/document/5.1.0/cn/overview/#shardingsphere-jdbc
定位为轻量级 Java 框架,在 Java 的 JDBC 层提供的额外服务,它使用客户端直连数据库,以 jar 包形式提供服务,无需额外部署和依赖,可理解为增强版的 JDBC 驱动,完全兼容 JDBC 和各种 ORM 框架。
- 适用于任何基于 JDBC 的 ORM 框架JPA, Hibernate, Mybatis, Spring JDBC Template 或直接使用 JDBC
- 支持任何第三方的数据库连接池DBCP, C3P0, BoneCP, HikariCP 等;
- 支持任意实现 JDBC 规范的数据库,目前支持 MySQLPostgreSQLOracleSQLServer 以及任何可使用 JDBC 访问的数据库。
这里我们主要演示一下水平分表方式我们直接创建一个新的SpringBoot项目即可依赖如下
```xml
<dependencies>
<!-- ShardingJDBC依赖那必须安排最新版啊希望你们看视频的时候还是5.X版本 -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.1.0</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
```
数据库我们这里直接用上节课的即可,因为只需要两个表结构一样的数据库即可,正好上节课进行了同步,所以我们直接把从库变回正常状态就可以了:
```sql
stop replica;
```
接着我们把两个表的root用户密码改一下一会用这个用户连接数据库
```sql
update user set authentication_string='' where user='root';
update user set host = '%' where user = 'root';
alter user root identified with mysql_native_password by '123456';
FLUSH PRIVILEGES;
```
接着我们来看,如果直接尝试开启服务器,那肯定是开不了的,因为我们要配置数据源:
![image-20220414212443482](https://tva1.sinaimg.cn/large/e6c9d24ely1h19kif2dh4j21sk08en0j.jpg)
那么数据源该怎么配置呢?现在我们是一个分库分表的状态,需要配置两个数据源:
```yaml
spring:
shardingsphere:
datasource:
# 有几个数据就配几个,这里是名称,按照下面的格式,名称+数字的形式
names: db0,db1
# 为每个数据源单独进行配置
db0:
# 数据源实现类这里使用默认的HikariDataSource
type: com.zaxxer.hikari.HikariDataSource
# 数据库驱动
driver-class-name: com.mysql.cj.jdbc.Driver
# 不用我多说了吧
jdbc-url: jdbc:mysql://192.168.0.8:3306/yyds
username: root
password: 123456
db1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.0.13:3306/yyds
username: root
password: 123456
```
如果启动没有问题,那么就是配置成功了:
![image-20220414222958901](https://tva1.sinaimg.cn/large/e6c9d24ely1h19mebpqb7j21su0aqgre.jpg)
接着我们需要对项目进行一些编写添加我们的用户实体类和Mapper
```java
@Data
@AllArgsConstructor
public class User {
int id;
String name;
String passwd;
}
```
```java
@Mapper
public interface UserMapper {
@Select("select * from test where id = #{id}")
User getUserById(int id);
@Insert("insert into test(id, name, passwd) values(#{id}, #{name}, #{passwd})")
int addUser(User user);
}
```
实际上这些操作都是常规操作在编写代码时关注点依然放在业务本身上现在我们就来编写配置文件我们需要告诉ShardingJDBC要如何进行分片首先明确现在是两个数据库都有test表存放用户数据我们目标是将用户信息分别存放到这两个数据库的表中。
不废话了,直接上配置:
```yaml
spring:
shardingsphere:
rules:
sharding:
tables:
#这里填写表名称,程序中对这张表的所有操作,都会采用下面的路由方案
#比如我们上面Mybatis就是对test表进行操作所以会走下面的路由方案
test:
#这里填写实际的路由节点,比如现在我们要分两个库,那么就可以把两个库都写上,以及对应的表
#也可以使用表达式,比如下面的可以简写为 db$->{0..1}.test
actual-data-nodes: db0.test,db1.test
#这里是分库策略配置
database-strategy:
#这里选择标准策略,也可以配置复杂策略,基于多个键进行分片
standard:
#参与分片运算的字段,下面的算法会根据这里提供的字段进行运算
sharding-column: id
#这里填写我们下面自定义的算法名称
sharding-algorithm-name: my-alg
sharding-algorithms:
#自定义一个新的算法,名称随意
my-alg:
#算法类型,官方内置了很多种,这里演示最简单的一种
type: MOD
props:
sharding-count: 2
props:
#开启日志,一会方便我们观察
sql-show: true
```
其中分片算法有很多内置的可以在这里查询https://shardingsphere.apache.org/document/5.1.0/cn/user-manual/shardingsphere-jdbc/builtin-algorithm/sharding/这里我们使用的是MOD也就是取模分片算法它会根据主键的值进行取模运算比如我们这里填写的是2那么就表示对主键进行模2运算根据数据源的名称比如db0就是取模后为0db1就是取模后为1官方文档描述的并不是很清楚也就是说最终实现的效果就是单数放在`db1`,双数放在`db0`,当然它还支持一些其他的算法,这里就不多介绍了。
那么现在我们编写一个测试用例来看看,是否能够按照我们上面的规则进行路由:
```java
@SpringBootTest
class ShardingJdbcTestApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
for (int i = 0; i < 10; i++) {
//这里ID自动生成1-10然后插入数据库
mapper.addUser(new User(i, "xxx", "ccc"));
}
}
}
```
现在我们可以开始运行了:
![image-20220415104401263](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a7m3cjm6j21qe0b6aej.jpg)
测试通过,我们来看看数据库里面是不是按照我们的规则进行数据插入的:
![image-20220415104449502](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a7mx6u30j217g0d075w.jpg)
可以看到这两张表都成功按照我们指定的路由规则进行插入了我们来看看详细的路由情况通过控制台输出的SQL就可以看到
![image-20220415105325917](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a7vvcqsij21g20amgpd.jpg)
可以看到所有的SQL语句都有一个Logic SQL这个就是我们在Mybatis里面写的是什么就是什么紧接着下面就是Actual SQL也就是说每个逻辑SQL最终会根据我们的策略转换为实际SQL比如第一条数据它的id是0那么实际转换出来的SQL会在db0这个数据源进行插入。
这样我们就很轻松地实现了分库策略。
分库完成之后,接着我们来看分表,比如现在我们的数据库中有`test_0`和`test_1`两张表表结构一样但是我们也是希望能够根据id取模运算的结果分别放到这两个不同的表中实现思路其实是差不多的这里首先需要介绍一下两种表概念
* **逻辑表:**相同结构的水平拆分数据库(表)的逻辑名称,是 SQL 中表的逻辑标识。 例:订单数据根据主键尾数拆分为 10 张表,分别是 `t_order_0``t_order_9`,他们的逻辑表名为 `t_order`
* **真实表:**在水平拆分的数据库中真实存在的物理表。 即上个示例中的 `t_order_0``t_order_9`
现在我们就以一号数据库为例,那么我们在里面创建上面提到的两张表,之前的那个`test`表删不删都可以,就当做不存在就行了:
```sql
create table test_0 (
`id` int primary key,
`name` varchar(255) NULL,
`passwd` varchar(255) NULL
);
create table test_1 (
`id` int primary key,
`name` varchar(255) NULL,
`passwd` varchar(255) NULL
);
```
![image-20220415110322981](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a8681w2ej211608u3z0.jpg)
接着我们不要去修改任何的业务代码Mybatis里面写的是什么依然保持原样即使我们的表名已经变了我们需要做的是通过路由来修改原有的SQL配置如下
```yaml
spring:
shardingsphere:
rules:
sharding:
tables:
test:
actual-data-nodes: db0.test_$->{0..1}
#现在我们来配置一下分表策略注意这里是table-strategy上面是database-strategy
table-strategy:
#基本都跟之前是一样的
standard:
sharding-column: id
sharding-algorithm-name: my-alg
sharding-algorithms:
my-alg:
#这里我们演示一下INLINE方式我们可以自行编写表达式来决定
type: INLINE
props:
#比如我们还是希望进行模2计算得到数据该去的表
#只需要给一个最终的表名称就行了test_后面的数字是表达式取模算出的
#实际上这样写和MOD模式一模一样
algorithm-expression: test_$->{id % 2}
#没错,查询也会根据分片策略来进行,但是如果我们使用的是范围查询,那么依然会进行全量查询
#这个我们后面紧接着会讲,这里先写上吧
allow-range-query-with-inline-sharding: false
```
现在我们来测试一下,看看会不会按照我们的策略进行分表插入:
![image-20220415112809843](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a8w0j0ubj21q80bkgq4.jpg)
可以看到,根据我们的算法,原本的逻辑表被修改为了最终进行分表计算后的结果,我们来查看一下数据库:
![image-20220415112908760](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a8x1l1coj21800lqgnj.jpg)
插入我们了解完毕了,我们来看看查询呢:
```java
@SpringBootTest
class ShardingJdbcTestApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
System.out.println(mapper.getUserById(0));
System.out.println(mapper.getUserById(1));
}
}
```
![image-20220415113139917](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a8znk6tuj21j408uacr.jpg)
可以看到,根据我们配置的策略,查询也会自动选择对应的表进行,是不是感觉有内味了。
那么如果是范围查询呢?
```java
@Select("select * from test where id between #{start} and #{end}")
List<User> getUsersByIdRange(int start, int end);
```
```java
@SpringBootTest
class ShardingJdbcTestApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
System.out.println(mapper.getUsersByIdRange(3, 5));
}
}
```
我们来看看执行结果会怎么样:
![image-20220415113530971](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a93ntucij21qa07wwim.jpg)
可以看到INLINE算法默认是不支持进行全量查询的我们得将上面的配置项改成true
```yaml
allow-range-query-with-inline-sharding: true
```
再次进行测试:
![image-20220415113652038](https://tva1.sinaimg.cn/large/e6c9d24ely1h1a952ev2ej21rq04wwg8.jpg)
可以看到最终出来的SQL语句是直接对两个表都进行查询然后求出一个并集出来作为最后的结果。
当然除了分片之外,还有广播表和绑定表机制,用于多种业务场景下,这里就不多做介绍了,详细请查阅官方文档。
### 分布式序列算法
前面我们讲解了如何进行分库分表,接着我们来看看分布式序列算法。
在复杂分布式系统中特别是微服构架中往往需要对大量的数据和消息进行唯一标识。随着系统的复杂数据的增多分库分表成为了常见的方案对数据分库分表后需要有一个唯一ID来标识一条数据或消息如订单号、交易流水、事件编号等此时一个能够生成全局唯一ID的系统是非常必要的。
比如我们之前创建过学生信息表、图书借阅表、图书管理表所有的信息都会有一个ID作为主键并且这个ID有以下要求
* 为了区别于其他的数据这个ID必须是全局唯一的。
* 主键应该尽可能的保持有序,这样会大大提升索引的查询效率。
那么我们在分布式系统下如何保证ID的生成满足上面的需求呢
1. **使用UUID**UUID是由一组32位数的16进制数字随机构成的我们可以直接使用JDK为我们提供的UUID类来创建
```java
public static void main(String[] args) {
String uuid = UUID.randomUUID().toString();
System.out.println(uuid);
}
```
结果为`73d5219b-dc0f-4282-ac6e-8df17bcd5860`,生成速度非常快,可以看到确实是能够保证唯一性,因为每次都不一样,而且这么长一串那重复的概率真的是小的可怜。
但是它并不满足我们上面的第二个要求也就是说我们需要尽可能的保证有序而这里我们得到的都是一些无序的ID。
2. **雪花算法Snowflake**
我们来看雪花算法它会生成一个一个64bit大小的整型的IDint肯定是装不下了。
![image-20220415150713707](https://tva1.sinaimg.cn/large/e6c9d24ely1h1af7y9xpyj213609kwf6.jpg)
可以看到它主要是三个部分组成,时间+工作机器ID+序列号时间以毫秒为单位41个bit位能表示约70年的时间时间纪元从2016年11月1日零点开始可以使用到2086年工作机器ID其实就是节点ID每个节点的ID都不相同那么就可以区分出来10个bit位可以表示最多1024个节点最后12位就是每个节点下的序列号因此每台机器每毫秒就可以有4096个系列号。
这样它就兼具了上面所说的唯一性和有序性了但是依然是有缺点的第一个是时间问题如果机器时间出现倒退那么就会导致生成重复的ID并且节点容量只有1024个如果是超大规模集群也是存在隐患的。
ShardingJDBC支持以上两种算法为我们自动生成ID文档https://shardingsphere.apache.org/document/5.1.0/cn/user-manual/shardingsphere-jdbc/builtin-algorithm/keygen/
这里我们就是要ShardingJDBC来让我们的主键ID以雪花算法进行生成首先是配置数据库因为我们默认的id是int类型装不下64位的改一下
```sql
ALTER TABLE `yyds`.`test` MODIFY COLUMN `id` bigint NOT NULL FIRST;
```
接着我们需要修改一下Mybatis的插入语句因为现在id是由ShardingJDBC自动生成我们就不需要自己加了
```java
@Insert("insert into test(name, passwd) values(#{name}, #{passwd})")
int addUser(User user);
```
接着我们在配置文件中将我们的算法写上:
```yaml
spring:
shardingsphere:
datasource:
sharding:
tables:
test:
actual-data-nodes: db0.test,db1.test
#这里还是使用分库策略
database-strategy:
standard:
sharding-column: id
sharding-algorithm-name: my-alg
#这里使用自定义的主键生成策略
key-generate-strategy:
column: id
key-generator-name: my-gen
key-generators:
#这里写我们自定义的主键生成算法
my-gen:
#使用雪花算法
type: SNOWFLAKE
props:
#工作机器ID保证唯一就行
worker-id: 666
sharding-algorithms:
my-alg:
type: MOD
props:
sharding-count: 2
```
接着我们来编写一下测试用例:
```java
@SpringBootTest
class ShardingJdbcTestApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
for (int i = 0; i < 20; i++) {
mapper.addUser(new User("aaa", "bbb"));
}
}
}
```
可以看到日志:
![image-20220415154524545](https://tva1.sinaimg.cn/large/e6c9d24ely1h1agbo7cqhj21fi0ein3c.jpg)
在插入的时候将我们的SQL语句自行添加了一个id字段并且使用的是雪花算法生成的值并且也是根据我们的分库策略在进行插入操作。
### 读写分离
最后我们来看看读写分离我们之前实现了MySQL的主从那么我们就可以将主库作为读从库作为写
![image-20220415155842834](https://tva1.sinaimg.cn/large/e6c9d24ely1h1agpivkf5j21og0ge76c.jpg)
这里我们还是将数据库变回主从状态,直接删除当前的表,我们重新来过:
```sql
drop table test;
```
我们需要将从库开启只读模式在MySQL配置中进行修改
```properties
read-only = 1
```
这样从库就只能读数据了但是root账号还是可以写数据接着我们重启服务器
```sh
sudo systemctl restart mysql.service
```
然后进入主库,看看状态:
![image-20220415160249024](https://tva1.sinaimg.cn/large/e6c9d24ely1h1agtsg7urj217k07s0tx.jpg)
现在我们配置一下从库:
```sql
change replication source to SOURCE_HOST='192.168.0.13',SOURCE_USER='test',SOURCE_PASSWORD='123456',SOURCE_LOG_FILE='binlog.000007',SOURCE_LOG_POS=19845;
start replica;
```
现在我们在主库创建表:
```sql
create table test (
`id` bigint primary key,
`name` varchar(255) NULL,
`passwd` varchar(255) NULL
);
```
然后我们就可以配置ShardingJDBC了打开配置文件
```yaml
spring:
shardingsphere:
rules:
#配置读写分离
readwrite-splitting:
data-sources:
#名称随便写
user-db:
#使用静态类型动态Dynamic类型可以自动发现auto-aware-data-source-name这里不演示
type: Static
props:
#配置写库(只能一个)
write-data-source-name: db0
#配置从库(多个,逗号隔开)
read-data-source-names: db1
#负载均衡策略,可以自定义
load-balancer-name: my-load
load-balancers:
#自定义的负载均衡策略
my-load:
type: ROUND_ROBIN
```
注意把之前改的用户实体类和Mapper改回去这里我们就不用自动生成ID的了。所有的负载均衡算法地址https://shardingsphere.apache.org/document/5.1.0/cn/user-manual/shardingsphere-jdbc/builtin-algorithm/load-balance/
现在我们就来测试一下吧:
```java
@SpringBootTest
class ShardingJdbcTestApplicationTests {
@Resource
UserMapper mapper;
@Test
void contextLoads() {
mapper.addUser(new User(10, "aaa", "bbb"));
System.out.println(mapper.getUserById(10));
}
}
```
运行看看SQL日志
![image-20220415162741466](https://tva1.sinaimg.cn/large/e6c9d24ely1h1ahjo682tj21je07udik.jpg)
可以看到当我们执行插入操作时会直接向db0进行操作而读取操作是会根据我们的配置选择db1进行操作。
至此,微服务应用章节到此结束。