Java微服务高级(下)

115 阅读33分钟

多级缓存

我们先来看看传统缓存方式存在的问题

image-20221016223728619

所谓的多级缓存方案指的是在服务器的多个环节中实现缓存的最大应用,我们会使用到Ngnix缓存+Redis缓存以及Tomcat的进程缓存,最后全部用不上再往数据库去请求

我们这里将Redis的缓存放于服务器前而非之后,这样就可以防止由于缓存失效给数据库造成的冲击问题

image-20221016224504971

项目导入

那么首先我们要学习这个内容,我们需要对我们的项目进行导入,首先我们需要停止我们虚拟机的Mysql

然后我们利用Docker来运行一个MySQL容器,首先启动Dcoker并导入对应的mysql资料到容器中

为了方便后期配置MySQL,我们先准备两个目录,用于挂载容器的数据和配置文件目录:

# 进入/tmp目录
cd /tmp
# 创建文件夹
mkdir mysql
# 进入mysql目录
cd mysql

进入mysql目录后,执行下面的Docker命令:

docker run \
 -p 3306:3306 \
 --name mysql \
 -v $PWD/conf:/etc/mysql/conf.d \
 -v $PWD/logs:/logs \
 -v $PWD/data:/var/lib/mysql \
 -e MYSQL_ROOT_PASSWORD=123 \
 --privileged \
 -d \
 mysql:5.7.25

这里指定端口为3306,密码为123

然后我们在/tmp/mysql/conf目录添加一个my.cnf文件,作为mysql的配置文件:

# 创建文件
touch /tmp/mysql/conf/my.cnf

文件的内容如下:

[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000

这里的配置内容都是一些mysql的信息配置,设置字符集和对应的信息而已

配置修改后,必须重启容器:

docker restart mysql

接下来,利用Navicat客户端连接MySQL,然后导入课前资料提供的sql文件:

image-20210809180936732

其中包含两张表:

  • tb_item:商品表,包含商品的基本信息
  • tb_item_stock:商品库存表,包含商品的库存信息

之所以将库存分离出来,是因为库存是更新比较频繁的信息,写操作较多。而其他信息修改的频率非常低。

然后我们导入课前提供的Demo工程item-service,将对应mysql的地址信息修改为自己的虚拟机信息

修改后,启动服务,访问:http://localhost:8081/item/10001即可查询数据

商品查询是购物页面,与商品管理的页面是分离的,我们需要准备一个反向代理的nginx服务器,将静态的商品页面放到nginx目录中

页面需要的数据通过ajax向服务端(nginx业务集群)查询

我们同样也提前准备好了,直接找到课前提供的nginx资源,将其拷贝到一个非中文目录下,运行这个nginx服务

运行命令:

start nginx.exe

然后访问 http://localhost/item.html?id=10001即可

暂时页面是假数据展示的。我们需要向服务器发送ajax请求,查询商品数据。

打开控制台,可以看到页面有发起ajax查询数据

image-20221017102119051

之所以直接访问localhost端口可以实际连接到我们的服务器,是因为我们在Nginx的配置文件中设置了对应的反向代理,查看nginx的conf目录下的nginx.conf文件:

其中的关键配置如下

image-20221017102424175

我们这里指定了监听的地址是localhost,端口80,也就是直接访问localhost就会被路由到上面我们指定的NGINX地址中,当然我们这里只配置了一个,所以怎么都只会路由到一个NGINX中的

到此为止我们的项目就导入完毕了

本地进程缓存

那么接着我们先来实现我们的本地进程缓存,也就是我们的Tomcat中的缓存

缓存可以基本分为两类,分别是分布式缓存和进程本地缓存,这两个缓存各有优劣,一般来说,我们在企业中都是这两种缓存结合使用的

image-20221016232716387

Caffeine(本地缓存)

实现进程本地缓存一般使用的框架是Caffeine,Spring内部缓存使用的就是Caffeine

image-20221016232921744

Caffeine提供了对应的方法用于存取数据,首先是创建缓存对象和存取数据的方法

image-20221016233110167

其示例代码如下

/*
  基本用法测试
 */
@Test
void testBasicOps() {
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder().build();
​
    // 存数据
    cache.put("gf", "迪丽热巴");
​
    // 取数据,不存在则返回null
    String gf = cache.getIfPresent("gf");
    System.out.println("gf = " + gf);
​
    // 取数据,不存在则去数据库查询
    String defaultGF = cache.get("defaultGF", key -> {
        // 这里可以去数据库根据 key查询value
        return "柳岩";
    });
    System.out.println("defaultGF = " + defaultGF);
}

Caffeine中提供了三种缓存驱逐策略,分别是基于容量、基于时间和基于引用,同时在Caffeine中,当缓存过期时,不会立即清楚,而是在一次读写操作完成或者在空闲时间里完成对过期数据的驱逐

image-20221016233529381

那么我们可以分别写入其测试代码如下

/*
 基于大小设置驱逐策略:
 */
@Test
void testEvictByNum() throws InterruptedException {
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
            // 设置缓存大小上限为 1
            .maximumSize(1)
            .build();
    // 存数据
    cache.put("gf1", "柳岩");
    cache.put("gf2", "范冰冰");
    cache.put("gf3", "迪丽热巴");
    // 延迟10ms,给清理线程一点时间
    Thread.sleep(10L);
    // 获取数据
    System.out.println("gf1: " + cache.getIfPresent("gf1"));
    System.out.println("gf2: " + cache.getIfPresent("gf2"));
    System.out.println("gf3: " + cache.getIfPresent("gf3"));
}
​
/*
 基于时间设置驱逐策略:
 */
@Test
void testEvictByTime() throws InterruptedException {
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofSeconds(1)) // 设置缓存有效期为 10 秒
            .build();
    // 存数据
    cache.put("gf", "柳岩");
    // 获取数据
    System.out.println("gf: " + cache.getIfPresent("gf"));
    // 休眠一会儿
    Thread.sleep(1200L);
    System.out.println("gf: " + cache.getIfPresent("gf"));
}

最后我们来实现商品查询本地的进程缓存,先来看看我们的案例需求

image-20221017004300198

那么首先我们要配置对应的缓存类并注册到Spring容器中,我们可以写入其代码如下

@Configuration
public class CaffeineConfig {

    @Bean
    public Cache<Long, Item> itemCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }

    @Bean
    public Cache<Long, ItemStock> stockCache(){
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .build();
    }
}

我们这里新创建了一个类,其下就创建了两个缓存类并指定了缓存的最大大小和初始值,这里缓存存储的是Item类和ItemStock类,也就是商品和商品数量这两个类

然后我们到控制层中首先注入这两个对象

@Autowired
private Cache<Long,Item> itemCache;
@Autowired
private Cache<Long,ItemStock> stockCache;

然后我们就可以通过缓存改造我们的代码,我们这里通过缓存对象的方法来返回数据,首先从缓存中查找数据,若查找不到则往数据库中查找数据,这里使用了Lambda表达式来简化开发

@GetMapping("/{id}")
public Item findById(@PathVariable("id") Long id){
    return itemCache.get(id,key -> itemService.query()
            .ne("status", 3).eq("id", id)
            .one()
    );
}

@GetMapping("/stock/{id}")
public ItemStock findStockById(@PathVariable("id") Long id){
    return stockCache.get(id,key -> stockService.getById(key));
}

Nginx进程缓存

接着我们来实现Nginx的进程缓存,由于Nginx内部是使用lua语法来进行开发的,我们要实现进程缓存也需要使用lua,因此我们在实现进程缓存在之前我们必须先学习Lua语法

Lua语法

首先我们先来看看Lua的介绍

image-20221017215016894

我们可以直接新建一个lua的后缀文件,内部写入打印语句,调用对应命令lua 文件名,即可令其运行

image-20221017215202553

lua中也有数据类型,具体介绍请看下图

image-20221017215436230

如果打印打印,即print(print),会显示function,这是当然,因为内部的print其实是一个函数

然后我们来看看其遍历的声明方式

image-20221017220156297

这里值得一提的是,无论是数组还是Map本质都是都是table类型,只不过前者table的下标是数字,从1开始,而后者的下标则是指定的字符串

循环的方式如下图所示,对于两种不同类型的table,其遍历的方式也略有不同

image-20221018141903300

同样我们也可以定义函数

image-20221017220905787

然后是条件控制

image-20221017221645405

最后我们可以来做一个案例加深理解,有兴趣的自己做吧,我反正懒得

image-20221017221740769

OpenResty

OpenResty是一个基于Nginx的高性能Web平台,其不但具有Nginx的完整功能,而且对Lua语言进行了扩展,集成了大量的库和模块,能够大大简化开发

image-20221017222305163

安装与启动

接着我们在我们的虚拟机里安装OpenResty并启动,首先你的Linux虚拟机必须联网

然后我们要安装OpenResty的依赖开发库,执行命令:

yum install -y pcre-devel openssl-devel gcc --skip-broken

接着我们要安装OpenResty仓库,在你的 CentOS 系统中添加 openresty 仓库,这样就可以便于未来安装或更新我们的软件包(通过 yum check-update 命令)。运行下面的命令就可以添加我们的仓库:

yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo

如果提示说命令不存在,则运行:

yum install -y yum-utils 

然后再重复上面的命令

接着就可以像下面这样安装软件包,比如 openresty

yum install -y openresty

然后我们需要安装OPM工具,opm是OpenResty的一个管理工具,可以帮助我们安装一个第三方的Lua模块。

如果你想安装命令行工具 opm,那么可以像下面这样安装 openresty-opm 包:

yum install -y openresty-opm

默认情况下,OpenResty安装的目录是:/usr/local/openresty

image-20221019103122289

看到里面的nginx目录了吗,OpenResty就是在Nginx基础上集成了一些Lua模块

接着我们要配nginx的环境变量

打开配置文件:

vi /etc/profile

在最下面加入两行:

export NGINX_HOME=/usr/local/openresty/nginx
export PATH=${NGINX_HOME}/sbin:$PATH

NGINX_HOME:后面是OpenResty安装目录下的nginx的目录

然后让配置生效:

source /etc/profile

OpenResty底层是基于Nginx的,所以运行方式与nginx基本一致:

# 启动nginx
nginx
# 重新加载配置
nginx -s reload
# 停止
nginx -s stop

nginx的默认配置文件注释太多,影响后续我们的编辑,这里将nginx.conf中的注释部分删除,保留有效部分。

修改/usr/local/openresty/nginx/conf/nginx.conf文件,内容如下:

#user  nobody;
worker_processes  1;
error_log  logs/error.log;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       8081;
        server_name  localhost;
        location / {
            root   html;
            index  index.html index.htm;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

在Linux的控制台输入命令以启动nginx:

nginx

然后访问页面:http://192.168.88.128:8081,注意ip地址替换为你自己的虚拟机IP:

快速入门

然后我们来做一个OpenResty快速入门的案例,来实现通过商品详情也进行数据查询,我这里的基本思路是让OpenResty接受来自前端的请求,然后返回一个假数据

image-20221017224632407

那么首先我们要做的事情是修改nginx.conf文件 image-20221017224906060

我们需要给其添加对对应的Lua模块的加载,其写在server框外,http框内

#lua 模块
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
#c模块     
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";  

然后我们需要还需要往server框内写入如下代码

        location ~ /api/item {
            # 默认的响应类型
            default_type application/json;
            # 响应结果由lua/item.lua文件来决定
            content_by_lua_file lua/item.lua;
        }

其可以拦截所有的item请求,并且指定响应的结果由lua文件夹中的item.lua文件来决定,同时还指定了响应类型为json格式类型

那么接着我们就需要编写item.lua文件

image-20221017225647251

这里我们需要返回一个假数据,返回假数据需要先复制之前的页面的数据,此处需要Vue Devtools工具,安装该工具请参考该网址blog.csdn.net/weixin_4384…,安装时发现还需要先安装node.js,此处请参考blog.csdn.net/qq_48485223…

安装完成之后进入对应页面点击Vue然后直接复制页面的参数即可,然后进入到对应的页面中,写入ngx.say('复制的内容'),然后重新加载配置即可

请求参数处理

OpenResty提供了各种API来获取不同类型的请求参数,我们的请求类型是第一种路径占位符的请求形式,那么我们就需要根据下图更改我们对应的代码

image-20221018143003640

那么我们首先需要将我们的拦截对应请求的代码改为如下

        location ~ /api/item/(\d+) {
            # 默认的响应类型
            default_type application/json;
            # 响应结果由lua/item.lua文件来决定
            content_by_lua_file lua/item.lua;
        }

然后我们需要可以在item.lua中获取传入的id,然后拼接到我们的返回页面的数据中,这里我们提示一下,Lua中拼接字符串的使用的是..

image-20221018143031293

Tomcat进程缓存

在实现Nginx的本地缓存之前,我们先来实现Tomcat的进程缓存,我们来实现向Tomcat的请求,Redis中的缓存我们之后再搞

image-20221018145341124

先来看看我们的案例步骤

image-20221018145451605

nginx内部提供了内部的API用以发送http请求,其下可以指定请求方式,还可以传参,返回的结果里有状态码、响应头和响应体,我们这里要关心的就只是响应体

image-20221018145839519

这里我们值得一提的是,path只是路径,该请求会被nginx内部的server监听并处理,因此我们需要编写一个server对该路径进行反向处理,这样才可以令我们的请求被nginx接受后准确发送到tomcat服务器中

这里值得一提的是,我们的Tomcat服务器和nginx的地址并不一样,虚拟机有自己的ip,而Tomcat在我们的windows上,这里我们有一个简单的方法,就是如果我们想要访问windows电脑的ip,只要拿虚拟机ip的前三位并且将最后一位改为0即可,使用该方法必须确保windows防火墙处于关闭状态

那么我们可以往nginx.conf的server框中写入其代码如下

        location /item {
           proxy_pass http://192.168.88.128:8081;
        }

其会拦截对应的没有调用api而是直接向服务器的请求,并将该请求正确指向到服务器中

然后我们需要封装http查询的函数,令其成为一个方法,首先我们需要进入lualib文件夹中,然后创建common.lua文件

image-20221018150729282

写入其代码如下

-- 封装函数,发送http请求,并解析响应
local function read_http(path, params)
    local resp = ngx.location.capture(path,{
        method = ngx.HTTP_GET,
        args = params,
    })
    if not resp then
        -- 记录错误信息,返回404
        ngx.log(ngx.ERR, "http查询失败, path: ", path , ", args: ", args)
        ngx.exit(404)
    end
    return resp.body
end
-- 将方法导出
local _M = {  
    read_http = read_http
}  
return _M

我们这里首先封装了对应的函数,然后我们最后将方法导出,这样便于后面别人的使用

我们需要最后还需要返回JSON结果,那么就需要将对象转换为JSON格式,OpenResty中提供了cjson的模块来实现JSON的序列化和反序列化

image-20221018161027542

然后我们只需要在item.lua中实现对应的逻辑即可,我们这里首先导入common函数库,然后获得common中向Tomcat发送http查询的方法,然后直接调用这两个方法,得到对应的结果然后将该结果组合并返回即可

那么最终我们可以写入我们的代码如下

image-20221019125300041

最终我们经过测试就可以发现此时我们的网页就可以正确从我们的服务器中获取数据了

虽然说Tomcat中有进程缓存,但是很不幸的是,在服务器里缓存是不可以共享的,而默认的路由方式又是轮询,这样的话,每次取服务器里都要向数据库请求,纯脱裤子放屁了属于是

image-20221018163030826

为了解决这个问题,Tomcat有有一种特殊的路由配置,其会根据请求的路径来设置哈希值,哈希值相同的请求会被路由到同一个服务器,不同的则会被路由到不同的路由器,这样只要请求的是同一个资源的路径,就肯定会到指定的服务器,这样就可以有效提高缓存的可用性,并正确利用集群了

那么由于现在我们的服务器是集群了,首先我们就应该更改我们的server框中的代码,因为令其指向Tomcat集群

        location /item {
           proxy_pass http://tomcat-cluster;
        }

在http框下,server框上指定tomcat集群的地址

    upstream tomcat-cluster {
        hash $request_uri;
        server 192.168.88.1:8081;
        server 192.168.88.1:8082;
    }

接着我们同样进行测试,会发现没有问题

Redis缓存

接着我们来增加Redis缓存,基本想法是让请求在请求Nginx缓存之后再请求Redis缓存,若Redis缓存没有对应资源再请求服务器缓存

image-20221018164042260

缓存预热

一般来说,为了防止给数据库带来过大压力,实际开发的时候我们都是利用大数据来统计用户访问的热点数据,在项目启动时提前将这些热点数据保存到Redis中

我们这里数据量就比较小,就全放入了

image-20221018164111780

首先我们需要利用Docker来安装Redis,输入下面的命令即可

然后在项目中引入对应的依赖并配置对应的Redis地址,配置完毕之后就需要编写初始化类

image-20221018164137422

搞定了之后我们新建一个RedisHandler类并实现InitialzingBean接口,实现其方法,然后注入对应的对象,MAPPER用于JSON序列化的对象

@Component
public class RedisHandler implements InitializingBean {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private IItemService itemService;
    @Autowired
    private IItemStockService stockService;

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public void afterPropertiesSet() throws Exception {
        // 初始化缓存
        // 1.查询商品信息
        List<Item> itemList = itemService.list();
        // 2.放入缓存
        for (Item item : itemList) {
            // 2.1 item序列化为JSON
            String json = MAPPER.writeValueAsString(item);
            // 2.2 存入redis
            redisTemplate.opsForValue().set("item:id:"+item.getId(),json);
        }

        // 初始化缓存
        // 3.查询商品库存信息
        List<ItemStock> stockList = stockService.list();
        // 4.放入缓存
        for (ItemStock stock : stockList) {
            // 2.1 item序列化为JSON
            String json = MAPPER.writeValueAsString(stock);
            // 2.2 存入redis
            redisTemplate.opsForValue().set("item:stock:id:"+ stock.getId(),json);
        }
    }
}

实现的方法中我们查询对应的商品信息和商品库存信息,然后将对应的结果存到Redis中,这里往不同类型的数据中都加入了固定前缀,这样可以防止重复key值的问题

缓存查询

接着我们来实现我们优先查询对应的缓存的功能,首先我们需要引入对应的操作Redis模块,然后设置对应的代码来初始化Redis对象并创建对应的Redis连接放入连接池,前者在Redis创建时就应该设置,后者则是封装为一个函数供给使用

image-20221018193938376

那么我们就要在我们的common.lua文件中加入初始化Redis对象的代码

 -- 导入Redis
local redis = require('resty.redis')
-- 初始化redis
local red = redis:new()
red:set_timeouts(1000, 1000, 1000)

然后是将连接放入连接池的函数

-- 关闭redis连接的工具方法,其实是放入连接池
local function close_redis(red)
    local pool_max_idle_time = 10000 -- 连接的空闲时间,单位是毫秒
    local pool_size = 100 --连接池大小
    local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
    if not ok then
        ngx.log(ngx.ERR, "放入redis连接池失败: ", err)
    end
end

然后我们来看看我们的案例需求

image-20221018195010848

既然每次都要查询往Redis中查询,我们大可以将其封装为一个函数,这样就能方便后续的调用

-- 查询redis的方法 ip和port是redis地址,key是查询的key
local function read_redis(ip, port, key)
    -- 获取一个连接
    local ok, err = red:connect(ip, port)
    if not ok then
        ngx.log(ngx.ERR, "连接redis失败 : ", err)
        return nil
    end
    -- 查询redis
    local resp, err = red:get(key)
    -- 查询失败处理
    if not resp then
        ngx.log(ngx.ERR, "查询Redis失败: ", err, ", key = " , key)
    end
    --得到的数据为空处理
    if resp == ngx.null then
        resp = nil
        ngx.log(ngx.ERR, "查询Redis数据为空, key = ", key)
    end
    close_redis(red)
    return resp
end

最后我们在导出的方法中当时将该方法也一起导出

-- 将方法导出
local _M = {  
    read_http = read_http,
    read_redis = read_redis
}  
return _M

那么我们可以在item.lua中写入代码如下

image-20221019143713327

Nginx本地缓存

然后我们来实现Nginx的本地缓存,OpenResty提供了shard dict的功能,可以在nginx的多个woker之间共享数据来实现缓存功能

image-20221018211532585

来看看我们的案例需求

image-20221018212000063

那么首先我们需要在nginx.conf中指定词典的名字和大小

    # 添加共享词典,本地缓存
    lua_shared_dict item_cache 150m;  

然后我们在item.lua中先导入我们的共享词典

-- 导入共享词典,本地缓存
local item_cache = ngx.shared.item_cache

然后修改其下的查询逻辑,令其先查询本地缓存,再查询redis,最后查询服务器

image-20221018214032395

那么最终其整体代码如下

-- 导入common函数库
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 导入cjson库
local cjson = require('cjson')
-- 导入共享词典,本地缓存
local item_cache = ngx.shared.item_cache

-- 封装查询函数
function read_data(key, expire, path, params)
    -- 查询本地缓存
    local val = item_cache:get(key)
    if not val then
        ngx.log(ngx.ERR, "本地缓存查询失败,尝试查询Redis,  key: ", key)
        -- 查询redis
        val = read_redis("127.0.0.1", 6379, key)
        -- 判断查询结果
        if not resp then
            ngx.log(ngx.ERR, "redis查询失败,尝试查询http,  key: ", key)
            -- redis查询失败,去查询http
            val = read_http(path, params)
        end
    end
    -- 查询成功,把数据写入本地缓存
    item_cache:set(key, val, expire)
    -- 返回数据
    return val
end

-- 获取路径参数
local id = ngx.var[1]

-- 查询商品信息
local itemJSON = read_data("item:id:" .. id, 1800, "/item/" .. id, nil)
-- 查询库存信息
local stockJSON = read_data("item:stock:id:" .. id, 60, "/item/stock/" .. id, nil)

-- JSON转化为lua的table
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
-- 组合数据
item.stock = stock.stock
item.sold = stock.sold

-- 返回结果
ngx.say(cjson.encode(item))

这里我们还指定了超时时间,让不同类型的数据拥有不同的更新时间

如果要查Nginx的Bug,可以往Nginx的中的logs日志中查看error.log文件,同时注意在OpenResty中打印错误信息需要在括号内指定ngx.ERR

Canal

缓存同步

缓存同步的常见方式有三种,分别是设置有效期、同步双写和异步通知,这三种方式,大部分情况下,我们都用第三种方式

image-20221019154520762

异步通知可以用MQ实现,但是MQ实现比较繁琐,因此我们不使用MQ

image-20221019154911822

我们使用Cannal来实现异步通知,其通过监听数据库的变化来通知对应的模块执行对应的更新

image-20221019155147312

初识Canal

canal是基于mysql的主从同步来实现的,下面是mysql主从同步的原理

image-20221019155318979

Canal将自己伪装成一个slave结点,从而监听master的binary log变化,然后将变化的信息通知给对应的客户端来完成数据同步

image-20221019155404286

Canal客户端提供了各种语言的实现,当Canal监听到数据库中的记录文件变化时,会对应通知Canal的客户端

image-20221019161219924

安装与启动

下面我们就开启mysql的主从同步机制,让Canal来模拟salve,Canal是基于MySQL的主从同步功能,因此必须先开启MySQL的主从功能才可以

首先打开mysql容器挂载的日志文件,我的在/tmp/mysql/conf目录

修改文件

vi /tmp/mysql/conf/my.cnf

添加内容

log-bin=/var/lib/mysql/mysql-bin
binlog-do-db=heima

配置解读

  • log-bin=/var/lib/mysql/mysql-bin:设置binary log文件的存放地址和文件名,叫做mysql-bin
  • binlog-do-db=heima:指定对哪个database记录binary log events,这里记录heima这个库

最终效果

[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000
log-bin=/var/lib/mysql/mysql-bin
binlog-do-db=heima

接下来添加一个仅用于数据同步的账户,出于安全考虑,这里仅提供对heima这个库的操作权限,将下文复制然后点开navicat新建一个查询并执行即可

create user canal@'%' IDENTIFIED by 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%' identified by 'canal';
FLUSH PRIVILEGES;

重启mysql容器

docker restart mysql

测试设置是否成功:在mysql控制台,或者Navicat中,输入命令:

show master status;

image-20221019191549010

我们需要创建一个网络,将MySQL、Canal、MQ放到同一个Docker网络中:

docker network create heima

让mysql加入这个网络:

docker network connect heima mysql

准备完成之后接着我们就来正式安装Canal

课前资料中提供了canal的镜像压缩包,上传到虚拟机,然后通过命令导入:

docker load -i canal.tar

然后运行命令创建Canal容器:

docker run -p 11111:11111 --name canal \
-e canal.destinations=heima \
-e canal.instance.master.address=mysql:3306  \
-e canal.instance.dbUsername=canal  \
-e canal.instance.dbPassword=canal  \
-e canal.instance.connectionCharset=UTF-8 \
-e canal.instance.tsdb.enable=true \
-e canal.instance.gtidon=false  \
-e canal.instance.filter.regex=heima\..* \
--network heima \
-d canal/canal-server:v1.1.5

说明:

  • -p 11111:11111:这是canal的默认监听端口
  • -e canal.instance.master.address=mysql:3306:数据库地址和端口,如果不知道mysql容器地址,可以通过docker inspect 容器id来查看
  • -e canal.instance.dbUsername=canal:数据库用户名
  • -e canal.instance.dbPassword=canal :数据库密码
  • -e canal.instance.filter.regex=:要监听的表名称

表名称监听支持的语法:

mysql 数据解析关注的表,Perl正则表达式.
多个正则之间以逗号(,)分隔,转义符需要双斜杠(\) 
常见例子:
1.  所有表:.*   or  .*\..*
2.  canal schema下所有表: canal\..*
3.  canal下的以canal打头的表:canal\.canal.*
4.  canal schema下的一张表:canal.test1
5.  多个规则组合使用然后以逗号隔开:canal\..*,mysql.test1,mysql.test2 

到此我们的Canal就安装完毕了

缓存同步实现

接着我们用Canal客户端来实现缓存同步,首先我们需要引入对应的Canal依赖,并编写配置

image-20221019161510195

然后我们需要编写监听器来监听Canal的消息,这里我们需要实现其提供的接口,接口类需要指定表捏关联的实体类,还需要使用对应的注解来监听指定的表

image-20221019161911928

Canal将消息推送给Canal的客户端的是对应的一行数据,其会封装到实体类中,而这个过程中我们需要对其配置对应的配置关系,需要去实体类里做对应的修改

image-20221019162338839

那么我们可以将我们的Item实体类修改如下

@Data
@TableName("tb_item")
public class Item {
    @TableId(type = IdType.AUTO)
    @Id
    private Long id;//商品id
    private String name;//商品名称
    private String title;//商品标题
    private Long price;//价格(分)
    private String image;//商品图片
    private String category;//分类名称
    private String brand;//品牌名称
    private String spec;//规格
    private Integer status;//商品状态 1-正常,2-下架
    private Date createTime;//创建时间
    private Date updateTime;//更新时间
    @TableField(exist = false)
    @Transient
    private Integer stock;
    @TableField(exist = false)
    @Transient
    private Integer sold;
}

我们这里对Id字段机械能了标记,然后还标记了不属于表中的库存字段

接着我们来实际编写对应的逻辑代码,由于通知本身要让Redis做的事情无非是增加和删除,因此我们这里先往对应的Redis配置类中添加这两个方法

public void saveItem(Item item){
    try {
        String json = MAPPER.writeValueAsString(item);
        redisTemplate.opsForValue().set("item:id:"+ item.getId(),json);
    } catch (JsonProcessingException e) {
        throw new RuntimeException();
    }
}

public void deleteItemById(Long id) {
    redisTemplate.delete("item:id"+id);
}

然后我们在客户端的代码中首先注入我们的Redis对象和Cache缓存对象,重写对应的三个方法,然后实现对进程缓存和Redis缓存的对应更新,由于JVM进程缓存是在本地的,因此对其进行对应的操作是比较快的,所以我们都先令其完成对应的缓存

@CanalTable("tb_item")
@Component
public class ItemHandler implements EntryHandler<Item> {

    @Autowired
    private RedisHandler redisHandler;
    @Autowired
    private Cache<Long,Item> itemCache;

    @Override
    public void insert(Item item) {
        // 写数据到JVM进程缓存
        itemCache.put(item.getId(),item);
        // 写数据到redis
        redisHandler.saveItem(item);
    }

    @Override
    public void update(Item before, Item after) {
        // 写数据到JVM进程缓存
        itemCache.put(after.getId(),after);
        // 写数据到redis
        redisHandler.saveItem(after);
    }

    @Override
    public void delete(Item item) {
        // 删除数据到JVM进程缓存
        itemCache.invalidate(item.getId());
        // 删除数据到redis
        redisHandler.deleteItemById(item.getId());
    }
}

最后我们对我们的多级缓存来做一个总结,多级缓存值得是在服务器的各个地方都尽可能的添加缓存来降低数据库的压力,具体结构请看下图

image-20221019164213630

这里值得一提的是,Nginx集群也是无法共享数据的,因此其也需要对应的哈希路由,跟我们在服务器里做的一样,当然,我们的案例中没有实现Nginx集群,但是我们知道就好了

MQ高级特性

我们之前学习的MQ存在消息可靠性问题、延迟消息问题、高可用问题和消息堆积问题,这些问题我们都需要解决

我们接下来就来一个个解决这些问题

image-20221019195328098

消息可靠性

消息从生产者发送到交换机再到队列再到消费者,中间都是有可能发生信息丢失的

image-20221019195606668

生产者确认机制

RabbitMQ提供了对应的生产者确定机制来避免消息丢失,其机制的原理就是消息到达每个对应区域或者出错了之后就返回对应的结果来告知生产者

同时为了区分不同的消息,其会给每个消息设置一个全局唯一id,这样生活者才能知道返回的结果是对应哪个消息的

image-20221019200413716

接着我们来AMQP中来实现我们上面所说的生产者确认机制,首先我们需要添加对应的配置

确认机制支持两种,第一种是消息发送之后生产者会一直等待结果,没结果就一直等,直到超时,这个方案我们并不建议,因为可能会造成阻塞,我们建议使用第二种方案correlated,异步回调,其将消息发送之后就干自己的事情了,而结果的发送则是让MQ通过调用事先设定的回调函数来实现的

上面的设置是开启回调等待结果的方式,并且设置的是当消息无法到达交换机时才会出发的回调函数,也就是confirmcallback

而publish-returns设置的是当消息从交换机路由不到queue时,才会触发ReturnCallback函数

最后设置的则是定义消息路由失败的策略,若为true则会调用回调函数,为false就不调用了直接丢弃消息

image-20221019201455560

最终我们的生成者模块的配置代码如下

logging:
  pattern:
    dateformat: HH:mm:ss:SSS
  level:
    cn.itcast: debug
spring:
  rabbitmq:
    host: 192.168.88.128 # rabbitMQ的ip地址
    port: 5672 # 端口
    username: itcast
    password: 123321
    virtual-host: /
    publisher-confirm-type: correlated
    publisher-returns: true
    template:
      mandatory: true

接着我们需要配置对应的回调函数,每一个RabbitTemplate只能配置一个回调函数,因此我们需要在项目启动的时候配置,这里我们使用ApplicationContextAware接口,使用该接口可以让我们的项目在启动时配置我们的设置的东西

image-20221019202430528

那么我们可以写入其代码如下

@Slf4j
@Configuration
public class CommonConfig implements ApplicationContextAware {

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        // 获取RabbitTemplate
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
        // 配置ReturnCallback
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            // 记录日志
            log.error("消息发送到队列失败,响应码:{},失败原因:{},交换机:{},路由key:{},消息:{}",
                        replyCode,replyText,exchange,routingKey,message.toString());
            // 如果有需要的话,重发消息
        });
    }
}

我们这里重写其方法,然后获得整个容器,从容器中取出对应的RabbitTemplate对象,然后往其中设置回调函数,函数中设置的就是是记录对应的日志而已,后面如果有需要的话还可以重发消息,我们这里就不设置了

然后我们再来写入对应的测试代码,其下要设置消息无法达到交换机时的回调函数

image-20221019202837273

那么我们可以写入我们的代码如下

@Test
public void testSendMessage2SimpleQueue() throws InterruptedException {
    // 1. 准备消息
    String message = "hello, spring amqp!";
    // 2. 准备CorrelationData
    // 2.1 消息ID
    CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
    // 2.2 准备ConfirmCallback
    correlationData.getFuture().addCallback(result -> {
        // 判断结果
        if(result.isAck()){
            // ACK
            log.debug("消息成功投递到交换机!消息ID:{}",correlationData.getId());
        }else {
            // NACK
            log.error("消息投递到交换机失败!消息ID:{}",correlationData.getId());
            // 重发消息
        }
    }, ex -> {
        // 记录日志
        log.error("消息发送失败!",ex);
        // 重发消息
    });
    // 3. 发送消息
    rabbitTemplate.convertAndSend("amq.topic", "simple.test", message,correlationData);
}

我们这里准备对应的消息,然后创建回调函数对象,往其中先设置id,然后调用内部的getFuture方法,这样就可以得到一个后续会得到结果的对象(因为这个结果并不是立刻会得到的,因此这里getFutrue的作用只是获得一个能够存放该结果的对象而已,也就是对将来对象的一个封装),然后再往内部设置回调函数,设置三种情况,第一种是设置成功,第二种是失败,第三种是发生异常,最后发送消息,发送消息是要设置发送的交换机和Routingkey,发送的消息和之前设置的具有唯一标识和回调函数的对象,便于后续的回调

最后我们来看看总结

image-20221019211813993

消费者确认机制

消费者同样有确认机制,消费者处理消息后向MQ发送回执,MQ收到之后才会删除该消息,其有manual、auto、none三种确定机制,我们推荐使用auto第二种,其利用的Spring的AOP原理实现的异常监听

image-20221020101949353

那么我们首先要做的事情就是进入消费者模块,修改其配置

然后只要开启消费者中定义一个异常再开启消费者,就可以看到对应的测试结果了

@Slf4j
@Component
public class SpringRabbitListener {

    @RabbitListener(queues = "simple.queue")
    public void listenSimpleQueue(String msg) {
        log.debug("消费者接收到simple.queue的消息:【" + msg + "】");
        System.out.println(1/0);
        log.info("消费者处理消息成功!");
    }
}

但是当消费者出现异常之后,消息会不断重新入队发送,无限循环,会给mq带来不必要的压力

解决这个问题,我们可以利用Spring的retry机制,让消费者出现异常时先进行几次规定的本地尝试,若均不成功再做对应的处理

这些设置同样要到配置文件中更改

image-20221020103040312

那么最终我们的配置文件修改如下

logging:
  pattern:
    dateformat: HH:mm:ss:SSS
  level:
    cn.itcast: debug
spring:
  rabbitmq:
    host: 192.168.88.128 # rabbitMQ的ip地址
    port: 5672 # 端口
    username: itcast
    password: 123321
    virtual-host: /
    listener:
      simple:
        prefetch: 1
        acknowledge-mode: auto
        retry:
          enabled: true
          initial-interval: 1000
          multiplier: 3
          max-attempts: 4

我们这里首先开启消费者重试,然后设置最初的等待时间为1s,等待的时长倍数为3倍,设置最大重试次数为4

消费者在消息失败后的处理策略有以下三种

image-20221020104041805

我们这类演示第三种方式,当重试的次数耗尽之后,我们将失败消息投递到指定的交换机并存放到错误队列中

image-20221020104411980

首先我们需要定义对应的交换机队列并指定其绑定关系,接着即使要定义发送消息的Bean,该Bean中要传入发送消息的RabbitTemplate对象,这样才可以发送消息,然后要指定对应的交换机和RoutingKey

那么我们可以写入其代码如下

@Configuration
public class ErrorMessageConfig {

    @Bean
    public DirectExchange errorMessageExchange(){
        return new DirectExchange("error.direct");
    }

    @Bean
    public Queue errorQueue(){
        return new Queue("error.queue");
    }

    @Bean
    public Binding errorMessageBinding(){
        return BindingBuilder.bind(errorQueue()).to(errorMessageExchange()).with("error");
    }

    @Bean
    public MessageRecoverer republishMessageRecover(RabbitTemplate rabbitTemplate){
        return new RepublishMessageRecoverer(rabbitTemplate,"error.direct","error");
    }
}

然后开启消费者就可以得到对应的结果,我们会发现保存到错误队列中的信息,不但有原来的内容,连错误信息都一起保存了,这样就能便于程序员的纠错

最后我们来看看总结

image-20221020110010408

消息持久化

MQ默认是内存存储消息,开始持久化功能才可以确保缓存在MQ中的消息不丢失

image-20221019213539793

要开启交换机持久化默认需要指定对应的参数为ture,而创建队列则需要调用其durable方法,而消息持久化也需要调用其对应的方法。

这里就不演示了,因为实际上在AMQP里,无论是交换机还是队列甚至是消息,默认创建时他们都是持久化的

死信交换机

先来认识下什么是死信交换机

image-20221020145842593

有的同学可能会说这不是跟之前学习过的消费者处理信息失败的策略三的方式几乎一样吗?的确,如果单纯是想要实现死信消息投递到指定交换机的话,那确实是一样的,但是死信交换机还有其他的功能,如果单纯是想要实现失败消息的转发的话,那我们还是推荐使用失败策略,最后我们来看看总结

image-20221020150017120

TTL

指的是队列中消息的存活时间,其分为两种情况,消息所在的队列设置了存活时间和消息本身设置了存活时间,如果两者都设置了,那么最终会取最小的时间作为存活时间

image-20221020150214821

接着我们就以上面这幅图为准来实现消息存放到另一个队列中并被消费者使用

首先我们来声明一组死信交换机和队列,我们这里基于注解方式来做

image-20221020150532035

那么我们可以在消费者模块中写入代码如下

@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "dl.queue", durable = "true"),
        exchange = @Exchange(name = "dl.direct"),
        key = "dl"
))
public void listenDlQueue(String msg){
    log.info("消费者接收到了dl.queue的延迟消息");
}

我们这里指定了对应的交换机,指定持久化和交换机还有RoutingKey,方法下就直接打印对应的信息即可,这样消费了信息就打印这句

接着我们需要设置超时时间并声明对应的超时时间

image-20221020152336481

那么我们可以写入代码如下

@Configuration
public class TTLMessageConfig {
    @Bean
    public DirectExchange ttlDirectExchange(){
        return new DirectExchange("ttl.direct");
    }

    @Bean
    public Queue ttlQueue(){
        return QueueBuilder
                .durable("ttl.queue")
                .ttl(10000)
                .deadLetterExchange("dl.direct")
                .deadLetterRoutingKey("dl")
                .build();
    }

    @Bean
    public Binding ttlBinding(){
        return BindingBuilder.bind(ttlQueue()).to(ttlDirectExchange()).with("ttl");
    }
}

我们这里做的事情是创建交换机和队列并绑定,进行一些必要的设置然后无了

最后是我们发送消息的时候,可以给消息自己设置超时时间

image-20221020152355912

我们这里又设置了消息的时间为5000,然后发送消息并记录日志

@Test
public void testTTLMessage() {
    // 1.准备消息
    Message message = MessageBuilder
            .withBody("hello, ttl message".getBytes(StandardCharsets.UTF_8))
            .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
            .setExpiration("5000")
            .build();
    // 2.发送消息
    rabbitTemplate.convertAndSend("ttl.direct","ttl", message);
    // 3.记录日志
    log.info("消息已经发送!");
}

最后我们来看看看我们的总结

image-20221020152817538

延迟队列

先来看看什么是延迟队列

image-20221020152934258

我们可以使用AMPQ提供的延迟来插件来实现该功能,首先我们来安装该插件,首先当然是导入对应的mq加载并安装了,此处安装的时候必须要指定其插件的文件夹,因此其启动代码如下

docker run \
 -e RABBITMQ_DEFAULT_USER=itcast \
 -e RABBITMQ_DEFAULT_PASS=123321 \
 -v mq-plugins:/plugins \
 --name mq \
 --hostname mq1 \
 -p 15672:15672 \
 -p 5672:5672 \
 -d \
 rabbitmq:3.8-management

然后我们要安装DelayExchange插件,我们这里已经提供好了,3.8.9的版本,其工作的原理是将消息保存,等待到一定时间之后再定向发送出去

因为我们是基于Docker安装,所以需要先查看RabbitMQ的插件目录对应的数据卷,

我们之前设定的RabbitMQ的数据卷名称为mq-plugins,所以我们使用下面命令查看数据卷:

docker volume inspect mq-plugins

可以得到下面结果:

image-20221020204321869

接下来,将插件上传到这个目录即可

最后就是安装了,需要进入MQ容器内部来执行安装。我的容器名为mq,所以执行下面命令:

docker exec -it mq bash

执行时,请将其中的 -it 后面的mq替换为你自己的容器名.

进入容器内部后,执行下面命令开启插件:

rabbitmq-plugins enable rabbitmq_delayed_message_exchange

安装完成之后即可使用,我们使用需要到其软件的控制台中去使用,我们需要手动创建并指定,这里创建的类型是x-delayed-message类型,具有延时效果

然后延迟交换机插件只负责延迟和消息路由,其路由方式也需要我们去指定,有三种指定方式

image-20221020204507396

而消息具体要延迟多久,这是需要发消息的时候去指定的

集成延迟队列

AMQP中可以使用延迟队列插件,本质延迟队列还是一个队列,因此我们只需要声明一个交换机即可使用,类型可以是任意的,只要设置delayed属性为true即可

image-20221020154332969

首先我们要设置对应的客户端,这里我们需要特别指定其为delayed的属性为true,其他的跟延迟队列时一样

@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "delay.queue", durable = "true"),
        exchange = @Exchange(name = "delay.direct", delayed = "true"),
        key = "delay"
))
public void listenDelayExchange(String msg){
    log.info("消费者接收到了delay.queue的延迟消息");
}

盎然我们也可以用Java代码的方式来创建,我们这里就不演示了

image-20221020154603116

然后我们要创建消息并发送

image-20221020154707483

写入其对应的代码如下

@Test
public void testSendDelayMessage() throws InterruptedException {
    // 1. 准备消息
    Message message = MessageBuilder
            .withBody("hello, ttl message".getBytes(StandardCharsets.UTF_8))
            .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
            .setHeader("x-delay", 5000)
            .build();
    // 2. 准备CorrelationData
    CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
    // 3. 发送消息
    rabbitTemplate.convertAndSend("delay.direct", "delay", message,correlationData);
    log.info("发送消息成功");
}

接着我们开启测试,会发现我们的生产者端却会报异常,这是因为我们的延时发送会让我们的消费者无法及时得到我们的消息,那么其就会由于没有接收到对应的消息而报异常结果

如果我们想要防止这个异常,可以改变我们的异常逻辑,让其判断返回的结果的延迟值是否大于0,若大于0则说明其为延迟消息,此时我们直接结束,不记录日志

@Slf4j
@Configuration
public class CommonConfig implements ApplicationContextAware {

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        // 获取RabbitTemplate
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
        // 配置ReturnCallback
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            // 判断是否是延迟消息,如果是则忽略该错误提示
            if(message.getMessageProperties().getReceivedDelay()>0){
                return;
            }
            // 记录日志
            log.error("消息发送到队列失败,响应码:{},失败原因:{},交换机:{},路由key:{},消息:{}",
                        replyCode,replyText,exchange,routingKey,message.toString());
            // 如果有需要的话,重发消息
        });
    }
}

那么之前我们实现TTL的时候为什么当时不会抛出返回对应的异常结果呢?我个人的理解是是因为我们延迟队列第一个队列压根就没有绑定消费者,延迟的时候其不会事先判断消息应该要发送到消费者中,因此不触发机制,而后面我们的延迟队列是指定了消费者的,此时其会判断消息应该要及时发送到消费者中,而实际上却没有,因此会触发机制

最后我们来看看总结

image-20221020160055332

消息堆积与惰性队列

当生产者发送消息的速度超过了消费者处理的速度时,会导致队列中的消息堆积,处理的堆积的思路往往是扩大消费者的消费速度或者是扩大队列容积两种,我们这里主要讲解后者

image-20221020213928631

在MQ3.6.0版本开始,为了解决消息堆积问题,MQ引入了Lazy Queues(惰性队列)的概念

惰性队列简单来说,是一种以降低性能为代价,但是能极大扩充队列容量的一种队列,其能够支持百万条的消息存储

image-20221020214204882

普通队列的消息是存储在内存中的,一旦队列满了,其就会停止收消息,然后将一部分消息写入磁盘,所以普通队列在面对大流量的情况,会出现时不时停止服务的情况,而惰性队列通过直接将消息存入磁盘的方式,解决了这个问题

然后我们来学习如何用AMQP的来声明惰性队列,其分为Bean和注解两种声明方式,前者较为简单,因此我们演示前者

image-20221020214356483

那么我们可以写入代码如下

@Configuration
public class LazyConfig {

    @Bean
    public Queue lazyQueue() {
        return QueueBuilder
                .durable("lazy.queue")
                .lazy()
                .build();
    }

    @Bean
    public Queue normalQueue() {
        return QueueBuilder
                .durable("normal.queue")
                .build();
    }
}

这里我们创建一个类然后内部添加对应的惰性队列和一个普通队列

接着我们添加两个测试方法,往惰性队列和普通队列都发送一百万条消息

@Test
public void testLazyQueue() throws InterruptedException {
    for (int i = 0; i < 1000000; i++) {
        // 1. 准备消息
        Message message = MessageBuilder
                .withBody("hello, Spring".getBytes(StandardCharsets.UTF_8))
                .setDeliveryMode(MessageDeliveryMode.NON_PERSISTENT)
                .build();
        // 2. 发送消息
        rabbitTemplate.convertAndSend("lazy.queue",message);
    }
}

@Test
public void testNormalQueue() throws InterruptedException {
    for (int i = 0; i < 1000000; i++) {
        // 1. 准备消息
        Message message = MessageBuilder
                .withBody("hello, Spring".getBytes(StandardCharsets.UTF_8))
                .setDeliveryMode(MessageDeliveryMode.NON_PERSISTENT)
                .build();
        // 2. 发送消息
        rabbitTemplate.convertAndSend("normal.queue",message);
    }
}

然后我们在控制台中,点击对应的队列可以看懂惰性队列会不断往磁盘中写入,内存中没有多少,而普通队列则是内存中有,磁盘中也有,此时就实现了我们想要的效果了

最后我们来看看总结

image-20221020215401719

MQ集群

MQ是基于Erlang语言编写的,而尬语言又天然支持集群,本章节我们来学习MQ集群

其集群有三种,分别是普通集群、镜像集群和仲裁队列,我们接下来就来讲解这三种集群方式

image-20221021015747085

  • 普通模式:普通模式集群不进行数据同步,每个MQ都有自己的队列、数据信息(其它元数据信息如交换机等会同步)。例如我们有2个MQ:mq1,和mq2,如果你的消息在mq1,而你连接到了mq2,那么mq2会去mq1拉取消息,然后返回给你。如果mq1宕机,消息就会丢失。
  • 镜像模式:与普通模式不同,队列会在各个mq的镜像节点之间同步,因此你连接到任何一个镜像节点,均可获取到消息。而且如果一个节点宕机,并不会导致数据丢失。不过,这种方式增加了数据同步的带宽消耗。

普通集群

首先我们来讲解普通集群

普通集群的每个MQ都会拥有其他MQ的对应队列的源信息,也就是指示队列的位置和的队列存储的数据量的信息,但是队列本身只会存在于对应的一个MQ中,如果有想要访问另一个MQ的队列的请求但是却访问了不对应的MQ,那么后者会通过队列源信息将对应MQ的队列信息取出并给到消费者

普通集群最大的问题在于其数据的安全性是不足够的,如果节点宕机,那么该结点中的队列消息就会消失

image-20221021015929787

我们先来看普通模式集群,我们的计划部署3节点的mq集群:

主机名控制台端口amqp通信端口
mq18081 ---> 156728071 ---> 5672
mq28082 ---> 156728072 ---> 5672
mq38083 ---> 156728073 ---> 5672

集群中的节点标示默认都是:rabbit@[hostname],因此以上三个节点的名称分别为:

  • rabbit@mq1
  • rabbit@mq2
  • rabbit@mq3

我们这里暴露不同的控制台端口,由于内部的端口都是15672,因此暴露控制台端口可以避免端口冲突问题

RabbitMQ底层依赖于Erlang,而Erlang虚拟机就是一个面向分布式的语言,默认就支持集群模式。集群模式中的每个RabbitMQ 节点使用 cookie 来确定它们是否被允许相互通信。

要使两个节点能够通信,它们必须具有相同的共享秘密,称为Erlang cookie。cookie 只是一串最多 255 个字符的字母数字字符。

每个集群节点必须具有相同的 cookie。实例之间也需要它来相互通信。

我们先在之前启动的mq容器中获取一个cookie值,作为集群的cookie。执行下面的命令:

docker exec -it mq cat /var/lib/rabbitmq/.erlang.cookie

可以看到cookie值如下:

FXZMCVGLBIXZCDEMMVZQ

接下来,停止并删除当前的mq容器,我们重新搭建集群。

docker rm -f mq

在/tmp目录新建一个配置文件 rabbitmq.conf:

cd /tmp
# 创建文件
touch rabbitmq.conf

文件内容如下:

loopback_users.guest = false
listeners.tcp.default = 5672
cluster_formation.peer_discovery_backend = rabbit_peer_discovery_classic_config
cluster_formation.classic_config.nodes.1 = rabbit@mq1
cluster_formation.classic_config.nodes.2 = rabbit@mq2
cluster_formation.classic_config.nodes.3 = rabbit@mq3

我们这里就指定了禁用guest用户,确保MQ安全,指定监听的端口,MQ通信就用此端口,然后下面的配置的是对应的集群信息

再创建一个文件,记录cookie

cd /tmp
# 创建cookie文件
touch .erlang.cookie
# 写入cookie
echo "FXZMCVGLBIXZCDEMMVZQ" > .erlang.cookie
# 修改cookie文件的权限
chmod 600 .erlang.cookie

准备三个目录,mq1、mq2、mq3:

cd /tmp
# 创建目录
mkdir mq1 mq2 mq3

然后拷贝rabbitmq.conf、cookie文件到mq1、mq2、mq3:

# 进入/tmp
cd /tmp
# 拷贝
cp rabbitmq.conf mq1
cp rabbitmq.conf mq2
cp rabbitmq.conf mq3
cp .erlang.cookie mq1
cp .erlang.cookie mq2
cp .erlang.cookie mq3

搞定了之后我们就可以正式来启动我们的集群了

首先创建一个网络:

docker network create mq-net

docker volume create

然后运行命令开启三个mq

docker run -d --net mq-net \
-v ${PWD}/mq1/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \
-v ${PWD}/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \
-e RABBITMQ_DEFAULT_USER=itcast \
-e RABBITMQ_DEFAULT_PASS=123321 \
--name mq1 \
--hostname mq1 \
-p 8071:5672 \
-p 8081:15672 \
rabbitmq:3.8-management
docker run -d --net mq-net \
-v ${PWD}/mq2/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \
-v ${PWD}/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \
-e RABBITMQ_DEFAULT_USER=itcast \
-e RABBITMQ_DEFAULT_PASS=123321 \
--name mq2 \
--hostname mq2 \
-p 8072:5672 \
-p 8082:15672 \
rabbitmq:3.8-management
docker run -d --net mq-net \
-v ${PWD}/mq3/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \
-v ${PWD}/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \
-e RABBITMQ_DEFAULT_USER=itcast \
-e RABBITMQ_DEFAULT_PASS=123321 \
--name mq3 \
--hostname mq3 \
-p 8073:5672 \
-p 8083:15672 \
rabbitmq:3.8-management

接着我们就可以进行测试了

在mq1这个节点上添加一个队列:

image-20221021104321348

如图,在mq2和mq3两个控制台也都能看到

image-20221021104335695

点击这个队列,进入管理页面,然后利用控制台发送一条消息到这个队列,结果在mq2、mq3上都能看到这条消息

image-20221021104404134

我们让其中一台节点mq1宕机:

docker stop mq1

然后登录mq2或mq3的控制台,发现simple.queue也不可用了,可以看到这里的状态显示down

image-20221021104422804

说明数据并没有拷贝到mq2和mq3

镜像模式

在刚刚的案例中,一旦创建队列的主机宕机,队列就会不可用。不具备高可用能力。如果要解决这个问题,必须使用官方提供的镜像集群方案

默认情况下,队列只保存在创建该队列的节点上。而镜像模式下,创建队列的节点被称为该队列的主节点,队列还会拷贝到集群中的其它节点,也叫做该队列的镜像节点。

但是,不同队列可以在集群中的任意节点上创建,因此不同队列的主节点可以不同。甚至,一个队列的主节点可能是另一个队列的镜像节点

用户发送给队列的一切请求,例如发送消息、消息回执默认都会在主节点完成,如果是从节点接收到请求,也会路由到主节点去完成。镜像节点仅仅起到备份数据作用

当主节点接收到消费者的ACK时,所有镜像都会删除节点中的数据。

总结如下:

  • 镜像队列结构是一主多从(从就是镜像)
  • 所有操作都是主节点完成,然后同步给镜像节点
  • 主宕机后,镜像节点会替代成新的主(如果在主从同步完成前,主就已经宕机,可能出现数据丢失)
  • 不具备负载均衡功能,因为所有操作都会有主节点完成(但是不同队列,其主节点可以不同,可以利用这个提高吞吐量)

其结构图和总结如下

image-20221021105034567

镜像模式的配置有3种模式

ha-modeha-params效果
准确模式exactly队列的副本量count集群中队列副本(主服务器和镜像服务器之和)的数量。count如果为1意味着单个副本:即队列主节点。count值为2表示2个副本:1个队列主和1个队列镜像。换句话说:count = 镜像数量 + 1。如果群集中的节点数少于count,则该队列将镜像到所有节点。如果有集群总数大于count+1,并且包含镜像的节点出现故障,则将在另一个节点上创建一个新的镜像。
all(none)队列在群集中的所有节点之间进行镜像。队列将镜像到任何新加入的节点。镜像到所有节点将对所有群集节点施加额外的压力,包括网络I / O,磁盘I / O和磁盘空间使用情况。推荐使用exactly,设置副本数为(N / 2 +1)。
nodesnode names指定队列创建到哪些节点,如果指定的节点全部不存在,则会出现异常。如果指定的节点在集群中存在,但是暂时不可用,会创建节点到当前客户端连接到的节点。

简单来说,第一个种方式会自动指定固定的结点数量来保存镜像结点的数据,这个保存镜像的结点数量是count值-1,因此我们的count值一定要设置地比0大,如果对应结点的镜像出故障,那么会在另外的结点上创建对应镜像

而all则是所有结点直接都要进行镜像,这个方式对集群的压力比较大

而nodes则是指定创建时的主节点和镜像结点究竟是哪个结点,是直接指定的方式

这里我们以rabbitmqctl命令作为案例来讲解配置语法

首先是exactly

rabbitmqctl set_policy ha-two "^two." '{"ha-mode":"exactly","ha-params":2,"ha-sync-mode":"automatic"}'
  • rabbitmqctl set_policy:固定写法

  • ha-two:策略名称,自定义

  • "^two.":匹配队列的正则表达式,符合命名规则的队列才生效,这里是任何以two.开头的队列名称

  • '{"ha-mode":"exactly","ha-params":2,"ha-sync-mode":"automatic"}': 策略内容

    • "ha-mode":"exactly":策略模式,此处是exactly模式,指定副本数量
    • "ha-params":2:策略参数,这里是2,就是副本数量为2,1主1镜像
    • "ha-sync-mode":"automatic":同步策略,默认是manual,即新加入的镜像节点不会同步旧的消息。如果设置为automatic,则新加入的镜像节点会把主节点中所有消息都同步,会带来额外的网络开销

然后是all

rabbitmqctl set_policy ha-all "^all." '{"ha-mode":"all"}'
  • ha-all:策略名称,自定义

  • "^all.":匹配所有以all.开头的队列名

  • '{"ha-mode":"all"}':策略内容

    • "ha-mode":"all":策略模式,此处是all模式,即所有节点都会称为镜像节点

最后是nodes

rabbitmqctl set_policy ha-nodes "^nodes." '{"ha-mode":"nodes","ha-params":["rabbit@nodeA", "rabbit@nodeB"]}'
  • rabbitmqctl set_policy:固定写法

  • ha-nodes:策略名称,自定义

  • "^nodes.":匹配队列的正则表达式,符合命名规则的队列才生效,这里是任何以nodes.开头的队列名称

  • '{"ha-mode":"nodes","ha-params":["rabbit@nodeA", "rabbit@nodeB"]}': 策略内容

    • "ha-mode":"nodes":策略模式,此处是nodes模式
    • "ha-params":["rabbit@mq1", "rabbit@mq2"]:策略参数,这里指定副本所在节点名称

注意我们这里的指定镜像的方式是对队列起作用的,能够让对应的队列采用不同的模式然后拥有不同的镜像效果,而不是对一个队列指定然后其下所有的队列都是一个效果的,这点要搞清楚

接着我们来做一个测试,我们这里使用exactly模式

我们使用exactly模式的镜像,因为集群节点数量为3,因此镜像数量就设置为2

运行下面的命令:

docker exec -it mq1 rabbitmqctl set_policy ha-two "^two." '{"ha-mode":"exactly","ha-params":2,"ha-sync-mode":"automatic"}'

然后我们创建一个新的队列

image-20221021113819534

在任意一个mq控制台查看队列

image-20221021113832117

给two.queue发送一条消息,然后在mq1、mq2、mq3的任意控制台可以查看到消息

让two.queue的主节点mq1宕机,查看队列状态

image-20221021114244634

发现依然是健康的!并且其主节点切换到了rabbit@mq2上

仲裁队列

从RabbitMQ 3.8版本开始,引入了新的仲裁队列,他具备与镜像队里类似的功能,但使用更加方便

image-20221021114756007

其配置方式非常简单,直接往控制台里就能完成,在任意控制台添加一个队列,一定要选择队列类型为Quorum类型

image-20221021114826157

在任意控制台查看队列

image-20221021114839098

可以看到,仲裁队列的 + 2字样。代表这个队列有2个镜像节点。

因为仲裁队列默认的镜像数为5。如果你的集群有7个节点,那么镜像数肯定是5;而我们集群只有3个节点,因此镜像数量就是3,这其实就相当于是All模式了, 当集群数量少于默认镜像数量的时候

接着我们来用AMQP来创建仲裁队列,其实非常简单,只要写入如下代码即可

image-20221021115012558

当然,我们的配置文件也需要进行对应的修改,因为我们这里配置的是集群,自然要将每个结点的地址都进行配置

image-20221021115038231

然后创建一个新的代码类并写入对应的Bean即可,但是这里值得一提的是,以前我们的写入的代码使用的是之前的MQ,还有一些甚至用了插件,但是之前的MQ我们已经删除了,所以为了避免造成干扰,我们需要删除掉那些代码,只保留我们的代码,然后启动就没有问题了‘