• 学习笔记:SpringCloud 微服务技术栈_高级篇④_多级缓存



    若文章内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系博主删除。


    前言



    • 写这篇博客旨在制作笔记,巩固知识。同时方便个人在线阅览,回顾知识。
    • 博客的内容主要来自视频内容和资料中提供的学习笔记。

    系列目录


    SpringCloud 微服务技术栈_实用篇①_基础知识

    SpringCloud 微服务技术栈_实用篇②_黑马旅游案例


    SpringCloud 微服务技术栈_高级篇①_微服务保护

    SpringCloud 微服务技术栈_高级篇②_分布式事务

    SpringCloud 微服务技术栈_高级篇③_分布式缓存

    SpringCloud 微服务技术栈_高级篇④_多级缓存

    SpringCloud 微服务技术栈_高级篇⑤_可靠消息服务


    微服务技术栈导学


    在这里插入图片描述


    在这里插入图片描述


    上一篇SpringCloud 微服务技术栈_高级篇③_分布式缓存


    16.什么是多级缓存


    16.1.传统缓存的问题


    传统的缓存策略一般是请求到达 Tomcat 后,先查询 Redis,如果未命中则查询数据库

    在这里插入图片描述
    传统存在的问题

    • 请求要经过 Tomcat 处理,Tomcat 的性能成为整个系统的瓶颈
    • Redis 缓存失效时,会对数据库产生冲击

    16.2.多级缓存的方案


    多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻 Tomcat 压力,提升服务性能

    • 浏览器访问静态资源时,优先读取浏览器本地缓存
    • 访问非静态资源(ajax 查询数据)时,访问服务端
    • 请求到达 Nginx 后,优先读取 Nginx 本地缓存
    • 如果 Nginx 本地缓存未命中,则去直接查询 Redis(不经过 Tomcat)
    • 如果 Redis 查询未命中,则查询 Tomcat
    • 请求进入 Tomcat 后,优先查询 JVM 进程缓存
    • 如果 JVM 进程缓存未命中,则查询数据库

    在这里插入图片描述


    在多级缓存架构中,Nginx 内部需要编写本地缓存查询、Redis 查询、Tomcat 查询的业务逻辑

    因此这样的 nginx 服务不再是一个 反向代理服务器,而是一个编写 业务的 Web 服务器了

    故这样的业务 Nginx 服务也需要搭建集群来提高并发,再有专门的 nginx 服务来做反向代理

    在这里插入图片描述


    另外,我们的 Tomcat 服务将来也会部署为集群模式

    在这里插入图片描述

    可见,多级缓存的关键有两个:

    • 一个是在 nginx 中编写业务,实现 nginx 本地缓存、Redis、Tomcat 的查询
    • 另一个就是在 Tomcat 中实现 JVM 进程缓存

    其中 Nginx 编程则会用到 OpenResty 框架结合 Lua 这样的语言。


    • 学习目标

    在这里插入图片描述


    17.JVM 基础缓存


    为了演示多级缓存的案例,我们先准备一个商品查询的业务。


    17.1.导入商品案例


    参考课前资料的:案例导入说明.md

    在这里插入图片描述


    17.1.1.Docker 给 MySQL 挂载本地目录


    1. 准备目录
    2. 运行命令
    3. 修改配置
    4. 重启

    • 在之前的 https://blog.csdn.net/yanzhaohanwei/article/details/125522469 中我们已经成功加载过 MySQL 镜像了。
    • 当时创建并运行了一个 MySQL 容器,将宿主机目录直接挂载到容器中的 /tmp 目录
    • Linux 系统会自动对 tmp(临时文件目录) 定期清理,故该数据卷在本地的挂载目录已经消失
    • 此外,为避免和之前的设置产生冲突,故这里建议删除掉之前创建的 MySQL 容器,从零开始
    • 相关 Docker 命令
      • docker images:查看所有镜像
      • docker ps -a:显示所有容器状态
      • docker rm imageName:删除掉之前创建的容器

    1. 准备目录

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

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

    1. 运行命令

    进入 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    • Bug 场景

    我这里出现了端口被占用的情况

    docker: Error response from daemon: 
    	driver failed programming external connectivity on endpoint mysql (...):
    		Error starting userland proxy: listen tcp4 0.0.0.0:3306: bind: address already in use.
    
    • 1
    • 2
    • 3

    使用命令 lsof -i:端口号 或命令 netstat -tunlp | grep 端口号 即可查看端口占用情况

    lsof -i:3306
    
    • 1
    netstat -tunlp | grep 3306
    
    • 1

    之后使用 kill -9 pid 命令强行终止该进程即可

    更多详情请见Linux 查看端口占用情况


    • Bug 场景

    若是出现了这一的错误信息

    docker: Error response from daemon: Conflict.
    The container name "/mysql" is already in use by container "...". 
    You have to remove (or rename) that container to be able to reuse that name.
    See 'docker run --help'.
    
    • 1
    • 2
    • 3
    • 4

    使用 docker ps -a 查看输出的容器信息,再使用 docker rm CONTAINERID 命令删除该容器即可


    1. 修改配置

    /tmp/mysql/conf 目录创建一个 my.cnf 文件,作为 mysql 的配置文件

    touch /tmp/mysql/conf/my.cnf
    
    • 1

    文件的内容如下

    [mysqld]
    skip-name-resolve
    character_set_server=utf8
    datadir=/var/lib/mysql
    server-id=1000
    
    • 1
    • 2
    • 3
    • 4
    • 5

    1. 重启

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

    docker restart mysql
    
    • 1

    17.1.2.导入 SQL 文件


    接下来,利用 Navicat 客户端连接 MySQL

    在这里插入图片描述


    创建数据库:heima

    在这里插入图片描述


    然后导入课前资料提供的 sql 文件(item.sql

    在这里插入图片描述


    其中包含两张表

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

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


    • tb_item:商品表(包含商品的基本信息)

    在这里插入图片描述


    • tb_item_stock:商品库存表(包含商品的库存信息)

    在这里插入图片描述


    17.1.3.导入 Demo 工程


    下面导入课前资料提供的工程:item-service

    在这里插入图片描述


    项目结构

    在这里插入图片描述


    17.1.4.案例业务介绍


    导入案例的业务包括

    1. 分页查询商品
    2. 新增商品
    3. 修改商品
    4. 修改库存
    5. 删除商品
    6. 根据 id 查询商品
    7. 根据 id 查询库存

    业务全部使用 MyBatis-Plus 来实现,如有需要请自行修改业务逻辑。


    1. 分页查询商品

    com.heima.item.web 包的 ItemController 中可以看到接口定义

    在这里插入图片描述


    1. 新增商品

    com.heima.item.web 包的 ItemController 中可以看到接口定义

    在这里插入图片描述


    1. 修改商品

    com.heima.item.web 包的 ItemController 中可以看到接口定义

    在这里插入图片描述


    1. 修改库存

    com.heima.item.web 包的 ItemController 中可以看到接口定义

    在这里插入图片描述


    1. 删除商品

    com.heima.item.web 包的 ItemController 中可以看到接口定义

    在这里插入图片描述

    这里是采用了逻辑删除,将商品状态修改为 3(商品状态:1-正常,2-下架,3-删除)


    1. 根据 id 查询商品

    com.heima.item.web 包的 ItemController 中可以看到接口定义

    在这里插入图片描述

    这里只返回了商品信息,不包含库存


    1. 根据 id 查询库存

    com.heima.item.web 包的 ItemController 中可以看到接口定义

    在这里插入图片描述


    17.1.5.启动案例


    注意修改 application.yml 文件中配置的 MySQL 地址信息

    server:
      port: 8081
    spring:
      application:
        name: itemservice
      datasource:
      	########################################################################################
        url: jdbc:mysql://192.168.150.101:3306/heima?useSSL=false # 诸位自己的虚拟机的 IP 地址
        username: root  # 诸位自己的账号
        password: 123   # 诸位自己的密码
        ########################################################################################
        driver-class-name: com.mysql.jdbc.Driver
    mybatis-plus:
      type-aliases-package: com.heima.item.pojo
      configuration:
        map-underscore-to-camel-case: true
      global-config:
        db-config:
          update-strategy: not_null
          id-type: auto
    logging:
      level:
        com.heima: debug
      pattern:
        dateformat: HH:mm:ss:SSS
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    需要修改为自己的虚拟机地址信息、还有账号和密码。

    修改后,启动服务。


    访问:http://localhost:8081/item/10001 即可查询数据


    17.1.6.导入商品查询页面


    1. 简述
    2. 运行 nginx 服务
    3. 反向代理

    1. 简述

    商品查询是购物页面,与商品管理的页面是分离的。

    部署方式如图

    在这里插入图片描述

    我们需要准备一个反向代理的 nginx 服务器,如上图红框所示,将静态的商品页面放到 nginx 目录中。

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


    1. 运行 nginx 服务

    课前资料的 nginx 文件夹中已经提供好了反向代理服务器和静态资源。

    在这里插入图片描述

    将其拷贝到一个非中文目录下,运行这个 nginx 服务。

    运行命令

    start nginx.exe
    
    • 1

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

    在这里插入图片描述


    1. 反向代理

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

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

    在这里插入图片描述

    而这个请求地址同样是 80 端口,所以被当前的 nginx 反向代理了。

    查看 nginxconf 目录下的 nginx.conf 文件

    在这里插入图片描述

    其中关键的配置如下

    在这里插入图片描述

    其中的 192.168.150.101 是视频中的虚拟机 IP,也就是视频中 Nginx 业务集群要部署的地方

    在这里插入图片描述

    nginx.conf 文件中的具体内容如下

    #user  nobody;
    worker_processes  1;
    
    events {
        worker_connections  1024;
    }
    
    http {
        include       mime.types;
        default_type  application/octet-stream;
    
        sendfile        on;
        #tcp_nopush     on;
        keepalive_timeout  65;
    
    	# Nginx 的业务集群,
    	# # 可以做 Nginx 本地缓存、Redis 本地缓存、Tomcat 查询
        upstream nginx-cluster{
            server 192.168.150.101:8081;
        }
        server {
            listen       80;
            server_name  localhost;
    
    	location /api {
                proxy_pass http://nginx-cluster;
            }
    
            location / {
                root   html;
                index  index.html index.htm;
            }
    
            error_page   500 502 503 504  /50x.html;
            location = /50x.html {
                root   html;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39

    17.2.初识 Caffeine


    17.2.1.缓存种类


    缓存在日常开发中启动至关重要的作用。

    由于缓存是存储在内存中的,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。


    我们把缓存分为两类

    • 分布式缓存。例如 Redis
      • 优点:存储容量更大、可靠性更好、可以在集群间共享
      • 缺点:访问缓存有网络开销
      • 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享
    • 进程本地缓存。例如 HashMapGuavaCache
      • 优点:读取本地内存,没有网络开销,速度更快
      • 缺点:存储容量有限、可靠性较低、无法共享
      • 场景:性能要求较高,缓存数据量较小

    本篇博客中会利用 Caffeine 框架来实现 JVM 进程缓存。


    17.2.2.Caffeine 简单介绍


    Caffeine是一个基于 Java8 开发的,提供了近乎最佳命中率的高性能的本地缓存库。

    目前 Spring 内部的缓存使用的就是 Caffeine。

    GitHub 地址:https://github.com/ben-manes/caffeine

    Caffeine 的性能非常好,下图是官方给出的性能对比

    在这里插入图片描述
    可以看到 Caffeine 的性能遥遥领先!


    17.2.3.缓存使用的基本 API


    可以通过 item-service 项目中的单元测试来学习 Caffine 的使用

    src/test/java/com/heima/item/test/CaffeineTest.java

    @Test
    void testBasicOps() {
        // 创建缓存对象
        Cache<String, String> cache = Caffeine.newBuilder().build();
    
        // 存数据
        cache.put("gf", "迪丽热巴");
    
        /* 取数据(方式一),不存在则返回 null */
        String gf = cache.getIfPresent("gf");
    
        /* 取数据(方式二),不存在则去数据库查询
            * 包含两个参数:
                * 参数一:缓存的 key
                * 参数二:Lambda 表达式,表达式参数就是缓存的 key,方法体是查询数据库的逻辑
         * 优先根据 key 查询 JVM 缓存;如果未命中,则执行参数二的 Lambda 表达式 */
        String defaultGF = cache.get("defaultGF", key -> {
            // 这里可以去数据库根据 key 查询 value
            return "柳岩";
        });
    
        System.out.println("gf = " + gf);
        System.out.println("defaultGF = " + defaultGF);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    输出结果

    gf = 迪丽热巴
    defaultGF = 柳岩
    
    • 1
    • 2

    17.2.4.Caffeine 的三种缓存驱逐策略


    Caffeine 既然是缓存的一种,肯定需要有缓存的清除策略,不然的话内存总会有耗尽的时候。


    Caffeine 提供了三种缓存驱逐策略

    1. 基于容量:设置缓存的数量上限
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(1) // 设置缓存大小上限为 1
        .build();
    
    • 1
    • 2
    • 3
    • 4
    1. 基于时间:设置缓存的有效时间
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
        // 设置缓存有效期为 10 秒,从最后一次写入开始计时 
        .expireAfterWrite(Duration.ofSeconds(10)) 
        .build();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    1. 基于引用:设置缓存为软引用或弱引用,利用 GC 来回收缓存数据。性能较差,不建议使用。

    注意

    • 在默认情况下,当一个缓存元素过期的时候,Caffeine 不会自动立即将其清理和驱逐
    • 而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐

    17.3.案例(实现 JVM 进程缓存)


    17.3.1.需求


    利用 Caffeine 实现下列需求

    • 给根据 id 查询商品的业务添加缓存,缓存未命中时查询数据库
    • 给根据 id 查询商品库存的业务添加缓存,缓存未命中时查询数据库
    • 缓存初始大小为 100
    • 缓存上限为 10000

    17.3.2.实现


    1. 我们需要定义两个 Caffeine 的缓存对象,分别保存商品、库存的缓存数据。

    item-servicecom.heima.item.config 包下定义 CaffeineConfig

    package com.heima.item.config;
    
    import com.github.benmanes.caffeine.cache.Cache;
    import com.github.benmanes.caffeine.cache.Caffeine;
    import com.heima.item.pojo.Item;
    import com.heima.item.pojo.ItemStock;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @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();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    1. 修改 item-service 中的 com.heima.item.web 包下的 ItemController 类,添加缓存逻辑
    @RestController
    @RequestMapping("item")
    public class ItemController {
    
        @Autowired
        private IItemService itemService;
        @Autowired
        private IItemStockService stockService;
    
        @Autowired
        private Cache<Long, Item> itemCache;
        @Autowired
        private Cache<Long, ItemStock> stockCache;
        
        // ...其它略
        
        @GetMapping("/{id}")
        public Item findById(@PathVariable("id") Long id) {
            return itemCache.get(id, key -> itemService.query()
                    .ne("status", 3).eq("id", key)
                    .one()
            );
        }
    
        @GetMapping("/stock/{id}")
        public ItemStock findStockById(@PathVariable("id") Long id) {
            return stockCache.get(id, key -> stockService.getById(key));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    18.Lua 语法入门


    Nginx 编程需要用到 Lua 语言,因此我们必须先入门 Lua 的基本语法。

    在这里插入图片描述


    18.1.初识 Lua


    Lua 是一种轻量小巧的脚本语言。

    Lua 用标准 C 语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

    Lua 官网https://www.lua.org/

    在这里插入图片描述

    Lua 经常嵌入到 C 语言开发的程序中,例如游戏开发、游戏插件等。

    Nginx 本身也是 C 语言开发,因此也允许基于 Lua 做拓展。


    18.2.HelloWorld


    CentOS7 默认已经安装了 Lua 语言环境,所以可以直接运行 Lua 代码。


    1. 在 Linux 虚拟机的任意目录下,新建一个 hello.lua 文件
    touch hello.lua
    
    • 1
    1. hello.lua 文件中添加下面的内容
    print("Hello World!")  
    
    • 1
    1. 结果

    在这里插入图片描述


    18.3.变量和循环


    学习任何语言必然离不开变量,而变量的声明必须先知道数据的类型。


    18.3.1.Lua 的数据类型


    Lua 中支持的常见数据类型包括

    数据类型描述
    nil这个最简单,只有值 nil 属于该类,表示一个无效值(在条件表达式中相当于 false)。
    boolean包含两个值:falsetrue
    number表示双精度类型的实浮点数
    string字符串由一对双引号或单引号来表示
    functionCLua 编写的函数
    tableLua 中的表(table)其实是一个 “关联数组” ( associative arrays)。

    数组的索引可以是数字、字符串或表类型。

    Lua 里,table 的创建是通过 “构造表达式” 来完成,最简单构造表达式是 {},用来创建一个空表。

    另外,Lua 提供了 type() 函数来判断一个变量的数据类型

    直接输入 lua 命令就可以进入 lua 控制台

    在这里插入图片描述


    18.3.2.声明变量


    Lua 声明变量的时候无需指定数据类型,而是用 local 来声明变量为局部变量

    -- 声明字符串,可以用单引号或双引号
    local str = 'hello'
    
    • 1
    • 2
    -- 字符串拼接可以使用 ..
    local str2 = 'hello' .. 'world'
    
    • 1
    • 2
    -- 声明数字
    local num = 21
    
    • 1
    • 2
    -- 声明布尔类型
    local flag = true
    
    • 1
    • 2

    Lua 中的 table 类型既可以作为数组,又可以作为 Java 中的 map 来使用。

    数组就是特殊的 table,key 是数组角标而已

    -- 声明数组 ,key 为角标的 table
    local arr = {'java', 'python', 'lua'}
    
    • 1
    • 2
    -- 声明 table,类似 java 的 map
    local map =  {name='Jack', age=21}
    
    • 1
    • 2

    Lua 中的数组角标是从 1 开始,访问的时候与 Java 中类似

    -- 访问数组,lua 数组的角标从 1 开始
    print(arr[1])
    
    • 1
    • 2

    Lua 中的 table 可以用 key 来访问

    -- 访问 table(第一种方式)
    print(map['name'])
    
    • 1
    • 2
    -- 访问 table(第二种方式)
    print(map.name)
    
    • 1
    • 2

    18.3.3.循环


    对于 table,我们可以利用 for 循环来遍历。不过数组和普通 table 遍历略有差异。


    遍历数组

    -- 声明数组 key为索引的 table
    local arr = {'java', 'python', 'lua'}
    
    • 1
    • 2
    -- 遍历数组
    for index,value in ipairs(arr) do
        print(index, value) 
    end
    
    • 1
    • 2
    • 3
    • 4

    遍历普通 table

    -- 声明 map,也就是 table
    local map = {name='Jack', age=21}
    
    • 1
    • 2
    -- 遍历 table
    for key,value in pairs(map) do
       print(key, value) 
    end
    
    • 1
    • 2
    • 3
    • 4

    在这里插入图片描述


    18.4.条件控制和函数


    Lua 中的条件控制和函数声明与 Java 类似。


    18.4.1.函数


    定义函数的语法

    function 函数名( argument1, argument2..., argumentn)
        -- 函数体
        return 返回值
    end
    
    • 1
    • 2
    • 3
    • 4

    例:定义一个函数,用来打印数组

    function printArr(arr)
        for index, value in ipairs(arr) do
            print(value)
        end
    end
    
    • 1
    • 2
    • 3
    • 4
    • 5

    18.4.2.条件控制


    类似 Java 的条件控制,例如 if、else 语法

    if(布尔表达式)
    then
       --[ 布尔表达式为 true 时执行该语句块 --]
    else
       --[ 布尔表达式为 false 时执行该语句块 --]
    end
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    与 java 不同,布尔表达式中的逻辑运算是基于英文单词

    操作符描述实例
    and逻辑与操作符。若 A 为 false,则返回 A,否则返回 B。(A and B) 为 false。
    or逻辑或操作符。若 A 为 true,则返回 A,否则返回 B。(A or B) 为 true。
    not逻辑非操作符。与逻辑运算结果相反,如果条件为 true,逻辑非为 false。not(A and B) 为 true。

    18.4.3.案例


    需求:自定义一个函数,可以打印 table,当参数为 nil 时,打印错误信息

    function printArr(arr)
        if (not arr) then
            print('数组不能为空!')
        end
        for index, value in ipairs(arr) do
            print(value)
        end
    end
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    19.实现多级缓存


    19.1.安装 OpenResty


    19.1.1.OpenResty 简单介绍


    OpenResty® 是一个基于 Nginx 的高性能 Web 平台

    • 用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。

    OpenResty® 具备下列特点

    • 具备 Nginx 的完整功能
    • 基于 Lua 语言进行扩展,集成了大量精良的 Lua 库、第三方模块
    • 允许使用 Lua 自定义业务逻辑自定义库

    官方网站https://openresty.org/cn

    在这里插入图片描述


    安装 Lua 可以参考课前资料提供的 安装OpenResty.md

    在这里插入图片描述


    19.1.2.安装开发库


    首先要安装 OpenResty 的依赖开发库,执行命令

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

    19.1.3.安装 OpenResty 仓库


    你可以在你的 CentOS 系统中添加 openresty 仓库

    这样就可以便于未来安装或更新我们的软件包(通过 yum check-update 命令)。

    运行下面的命令就可以添加我们的仓库

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

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

    yum install -y yum-utils 
    
    • 1

    然后再重复上面的命令


    19.1.4.安装 OpenResty


    然后就可以像下面这样安装软件包

    比如 openresty

    yum install -y openresty
    
    • 1

    19.1.5.安装 opm 工具


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

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

    yum install -y openresty-opm
    
    • 1

    19.1.6.简单了解目录结构


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

    在这里插入图片描述

    显然,/usr/local/openresty\bin\ 目录下的 openresty 文件是个软链接,指向 /usr/local/openresty/nginx/sbin 目录下的 nginx


    /usr/local/openresty 目录下有 nginx 目录

    OpenResty 就是在 Nginx 基础上集成了一些 Lua 模块。

    在这里插入图片描述


    19.1.7.配置 nginx 的环境变量


    打开配置文件

    vi /etc/profile
    
    • 1

    在最下面加入两行

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

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

    然后让配置生效

    source /etc/profile
    
    • 1

    19.1.8.启动、配置、运行


    OpenResty 底层是基于 Nginx 的,查看 OpenResty 目录的 nginx 目录,结构与 windows 中安装的 nginx 基本一致

    在这里插入图片描述


    所以运行方式与 nginx 基本一致

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

    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;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

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

    nginx
    
    • 1

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

    在这里插入图片描述


    19.1.9.备注


    加载 OpenResty 的 lua 模块

    # lua 模块
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";
    # c 模块     
    lua_package_cpath "/usr/local/openresty/lualib/?.so;;";  
    
    • 1
    • 2
    • 3
    • 4

    common.lua

    -- 封装函数,发送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 not found, path: ", path , ", args: ", args)
            ngx.exit(404)
        end
        return resp.body
    end
    -- 将方法导出
    local _M = {  
        read_http = read_http
    }  
    return _M
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    释放 Redis 连接 API

    -- 关闭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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    读取 Redis 数据的 API

    -- 查询 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    开启共享词典

    # 共享字典,也就是本地缓存,名称叫做:item_cache,大小 150m
    lua_shared_dict item_cache 150m; 
    
    • 1
    • 2

    19.2.OpenResty 快速入门


    我们希望达到的多级缓存架构如图

    在这里插入图片描述

    其中

    • windows 上的 nginx 用来做反向代理服务,将前端的查询商品的 ajax 请求代理到 OpenResty 集群
    • OpenResty 集群用来编写多级缓存业务

    19.2.1.反向代理流程


    现在,商品详情页使用的是假的商品数据。


    不过在浏览器中,可以看到页面有发起 ajax 请求查询真实商品数据。这个请求如下

    在这里插入图片描述


    请求地址是 localhost,端口是 80,就被 windows 上安装的 Nginx 服务给接收到了。然后代理给了 OpenResty 集群

    在这里插入图片描述


    我们需要在 OpenResty 中编写业务,查询商品数据并返回到浏览器。

    但是这次,我们先在 OpenResty 接收请求,返回假的商品数据。


    19.2.2.OpenResty 监听请求


    OpenResty 的很多功能都依赖于其目录下的 Lua 库,需要在 nginx.conf 中指定依赖库的目录,并导入依赖


    1. 添加对 OpenResty 的 Lua 模块的加载

    修改/usr/local/openresty/nginx/conf/nginx.conf文件

    在其中的 http{} 中,添加下面代码

    # 指定 lua 模块
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";
    # 指定 c 模块     
    lua_package_cpath "/usr/local/openresty/lualib/?.so;;";  
    
    • 1
    • 2
    • 3
    • 4

    1. 监听 /api/item 路径

    修改 /usr/local/openresty/nginx/conf/nginx.conf 文件

    nginx.confserver{} 中,添加对 /api/item 这个路径的监听

    location /api/item {
        # 默认的响应类型
        default_type application/json;
        # 响应结果由 lua/item.lua 文件来决定
        content_by_lua_file lua/item.lua;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    这个监听,就类似于 SpringMVC 中的 @GetMapping("/api/item") 做路径映射。

    content_by_lua_file lua/item.lua 则相当于调用 item.lua 这个文件,执行其中的业务,把结果返回给用户。

    相当于 java 中调用 service


    19.2.3.编写 item.lua


    1. /usr/loca/openresty/nginx 目录创建文件夹:lua
    mkdir lua
    
    • 1

    1. /usr/loca/openresty/nginx/lua 文件夹下,新建文件:item.lua
    touch lua/item.lua
    
    • 1

    在这里插入图片描述


    1. 编写 item.lua,返回假数据

    item.lua中,利用 ngx.say() 函数返回数据到 Response

    ngx.say('{"id":10001,"name":"SALSA AIR","title":"RIMOWA 26寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":19900,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp","category":"拉杆箱","brand":"RIMOWA","spec":"","status":1,"createTime":"2019-04-30T16:00:00.000+00:00","updateTime":"2019-04-30T16:00:00.000+00:00","stock":2999,"sold":31290}')
    
    • 1

    对于视频中出现的 Vue Devtools,诸位可以参考这篇博客安装该工具:【在浏览器上安装 Vue Devtools 工具】


    1. 重新加载配置
    nginx -s reload
    
    • 1

    刷新商品页面:http://localhost/item.html?id=1001,即可看到效果

    在这里插入图片描述


    19.3.请求参数处理


    在 19.2 中,我们 在OpenResty 接收前端请求,但是返回的是假数据。

    要返回真实数据,必须根据前端传递来的商品 id,查询商品信息才可以。

    那么如何获取前端传递的商品参数呢?


    19.3.1.获取参数的 API


    OpenResty 中提供了一些 API 用来获取不同类型的前端请求参数

    在这里插入图片描述


    19.3.2.获取参数并返回


    在前端发起的 ajax 请求如图

    在这里插入图片描述

    可以看到商品 id 是以路径占位符方式传递的,因此可以利用正则表达式匹配的方式来获取 ID


    需求:在 OpenResty 中接收这个请求,并获取路径中的 id 信息,拼接到家结果的 json 字符串中返回


    1. 获取商品 id

    修改/usr/loca/openresty/nginx/nginx.conf文件中监听 /api/item 的代码,利用正则表达式获取 ID

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

    1. 拼接 ID 并返回

    修改/usr/loca/openresty/nginx/lua/item.lua文件,获取 id 并拼接到结果中返回

    -- 获取商品 id
    local id = ngx.var[1]
    -- 拼接并返回
    ngx.say('{"id":' .. id .. ',"name":"SALSA AIR","title":"RIMOWA 26寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":19900,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp","category":"拉杆箱","brand":"RIMOWA","spec":"","status":1,"createTime":"2019-04-30T16:00:00.000+00:00","updateTime":"2019-04-30T16:00:00.000+00:00","stock":2999,"sold":31290}')
    
    • 1
    • 2
    • 3
    • 4

    1. 重新加载并测试

    运行命令以重新加载 OpenResty 配置

    nginx -s reload
    
    • 1

    刷新页面可以看到结果中已经带上了 ID,http://localhost/item.html?id=10002

    在这里插入图片描述


    19.4.查询 Tomcat


    拿到商品 ID后,本应去缓存中查询商品信息,不过目前我们还未建立 nginx、redis 缓存。

    因此,这里我们先根据商品 id 去 tomcat 查询商品信息。

    我们实现如图部分

    在这里插入图片描述


    需要注意的是,我们的 OpenResty 是在虚拟机,Tomcat 是在 Windows 电脑上。

    两者 IP 一定不要搞错了。

    在这里插入图片描述


    案例

    • 获取请求路径中的商品 id 信息,根据 id 向 Tomcat 查询商品信息

    这里要修改 item.lua ,满足下面的需求

    1. 获取请求参数中的 id
    2. 根据 id 向 Tomcat 服务发送请求,查询商品信息
    3. 根据 id 向 Tomcat 服务发送请求,查询库存信息
    4. 组装商品信息、库存信息,序列化为 JSON 格式并返回

    19.4.1.发送 http 请求的 API


    nginx 提供了内部 API 用以发送 http 请求

    local resp = ngx.location.capture("/path",{
        method = ngx.HTTP_GET,   -- 请求方式
        args = {a=1,b=2},  -- get 方式传参数
    })
    
    • 1
    • 2
    • 3
    • 4

    以下两种传递方式只能选一种,而不能同时都选

    method = ngx.HTTP_GET,   -- 请求方式
    args = {a=1,b=2}, -- get 方式传递参数
    
    • 1
    • 2
    method = ngx.HTTP_POST,   -- 请求方式
    body = "c=3&d=4" -- post 方式传递参数
    
    • 1
    • 2

    返回的响应内容包括

    • resp.status:响应状态码
    • resp.header:响应头,是一个 table
    • resp.body:响应体,就是响应数据

    注意这里的 path 是路径,并不包含 IP 和端口

    这个请求会被 nginx 内部的 server 监听并处理。


    但是我们希望这个请求发送到 Tomcat 服务器,所以还需要编写一个 server 来对这个路径做反向代理

    location /path {
        # 这里是 windows 电脑的 ip 和 Java 服务端口,需要确保 windows 防火墙处于关闭状态
        proxy_pass http://192.168.150.1:8081; 
    }
    
    • 1
    • 2
    • 3
    • 4

    注意

    • 此处的 location /path {} 应该写 location /item {}
    • 如下代码块所示,在本项目里的请求数据都是 item 开头的
    @RequestMapping("item")
    public class ItemController {}
    
    • 1
    • 2

    原理如图

    在这里插入图片描述

    注意如果符合以下两种情况都会无法机加载数据

    • 反向代理的 proxy_apss 中的地址和本机 IP 地址一样会报错
    • 配置 ip 地址若与 默认网关地址相同,也会报错
      • 比如默认网关是 192.168.150.1配成 192.168.150.2 就没有问题了

    19.4.2.封装 http 工具


    下面,我们封装一个发送 Http 请求的工具,基于 ngx.location.capture v来实现查询 tomcat。

    1. 添加反向代理,到 windows 的 Java 服务
    2. 封装工具类
    3. 实现商品查询

    1. 添加反向代理,到 windows 的 Java 服务

    为item-service 中的接口都是 /item 开头,所以我们监听 /item 路径,代理到 windows 上的 tomcat 服务。

    修改 /usr/local/openresty/nginx/conf/nginx.conf 文件,添加一个 location

    location /item {
        proxy_pass http://192.168.150.1:8081;
    }
    
    • 1
    • 2
    • 3

    以后,只要我们调用 ngx.location.capture("/item"),就一定能发送请求到 windows 的 tomcat 服务。


    1. 封装工具类

    之前我们说过,OpenResty 启动时会加载以下两个目录中的工具文件

    在这里插入图片描述


    所以,自定义的 http 工具也需要放到这个目录下。

    /usr/local/openresty/lualib 目录下,新建一个 common.lua 文件

    vi /usr/local/openresty/lualib/common.lua
    
    • 1

    内容如下

    -- 封装函数,发送 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    这个工具将 read_http 函数封装到 _M 这个 table 类型的变量中,并且返回,这类似于导出。

    使用的时候,可以利用 require('common') 来导入该函数库,这里的 common 是函数库的文件名。


    1. 实现商品查询

    最后,我们修改 /usr/local/openresty/lua/item.lua 文件,利用刚刚封装的函数库实现对 tomcat 的查询

    -- 引入自定义 common 工具模块,返回值是 common 中返回的 _M
    local common = require("common")
    -- 从 common 中获取 read_http 这个函数
    local read_http = common.read_http
    -- 获取路径参数
    local id = ngx.var[1]
    -- 根据 id 查询商品
    local itemJSON = read_http("/item/" .. id, nil)
    -- 根据 id 查询商品库存
    local itemStockJSON = read_http("/item/stock/" .. id, nil)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这里查询到的结果是 json 字符串,并且包含商品、库存两个 json 字符串

    页面最终需要的是把两个 json 拼接为一个 json

    在这里插入图片描述


    19.4.3.CJSON 工具类


    OpenResty 提供了一个 cjson 的模块用来处理 JSON 的序列化和反序列化。

    官方地址: https://github.com/openresty/lua-cjson/


    1. 引入 cjson 模块
    local cjson = require "cjson"
    
    • 1

    1. 序列化
    local obj = {
        name = 'jack',
        age = 21
    }
    -- 把 table 序列化为 json
    local json = cjson.encode(obj)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    1. 反序列化
    local json = '{"name": "jack", "age": 21}'
    -- 反序列化 json为 table
    local obj = cjson.decode(json);
    print(obj.name)
    
    • 1
    • 2
    • 3
    • 4

    19.4.4.实现 Tomcat 查询


    下面,我们修改之前的 item.lua 中的业务,添加 json 处理功能

    -- 引入自定义 common 工具模块,返回值是 common 中返回的 _M
    local common = require('common')
    
    -- 导入 cjson 库
    local cjson = require('cjson')
    
    -- 从 common 中获取 read_http 这个函数
    local read_http = common.read_http
    
    -- 获取路径参数
    local id = ngx.var[1]
    
    -- 根据 id 查询商品
    local itemJSON = read_http("/item/" .. id, nil)
    
    -- 根据 id 查询商品库存
    local itemStockJSON = read_http("/item/stock/" .. id, nil)
    
    -- JSON 转化为 lua 的 table
    local item = cjson.decode(itemJSON)
    local stock = cjson.decode(itemStockJSON)
    
    -- 组合数据
    item.stock = stock.stock
    item.sold = stock.sold
    
    -- 把 item 序列化为 json 并返回结果
    ngx.say(cjson.encode(item))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    完成了上面的操作后,我们发现服务不同的 id 时,出现的数据图片也不一样了

    在这里插入图片描述


    19.4.5.基于 ID 负载均衡


    1. 原因
    2. 原理
    3. 实现
    4. 测试

    1. 原因

    刚才的代码中,我们的 tomcat 是单机部署。

    但在实际开发中,tomcat 一定是集群模式。

    在这里插入图片描述

    因此,OpenResty 需要对 tomcat 集群做负载均衡。

    而默认的负载均衡规则是轮询模式,当我们查询 /item/10001

    • 第一次会访问 8081 端口的 tomcat 服务,在该服务内部就形成了 JVM 进程缓存
    • 第二次会访问 8082 端口的 tomcat 服务,该服务内部没有 JVM 缓存(因为 JVM 缓存无法共享),会查询数据库

    在这里插入图片描述

    因为轮询的原因,第一次查询 8081 形成的 JVM 缓存并未生效,直到下一次再次访问到 8081 时才可以生效。

    这样的话缓存命中率太低了。那么怎么办呢?

    如果能让同一个商品,每次查询时都访问同一个 tomcat 服务,那么 JVM 缓存就一定能生效了。

    也就是说,我们需要根据商品 id 做负载均衡,而不是轮询。


    1. 原理

    nginx 提供了基于请求路径做负载均衡的算法

    nginx 根据请求路径做 hash 运算,把得到的数值对 tomcat 服务的数量取余,余数是几,就访问第几个服务,实现负载均衡。

    例如

    • 我们的请求路径是 /item/10001
    • tomcat 总数为 2 台(8081、8082)
    • 对请求路径 /item/1001 做 hash 运算求余的结果为 1
    • 则访问第一个 tomcat 服务,也就是 8081

    只要 id 不变,每次 hash 运算结果也不会变,那就可以保证同一个商品,一直访问同一个 tomcat 服务,确保 JVM 缓存生效。


    1. 实现

    修改 /usr/local/openresty/nginx/conf/nginx.conf 文件,实现基于 ID 做负载均衡。

    首先,定义 tomcat 集群,并设置基于路径做负载均衡

    upstream tomcat-cluster {
        hash $request_uri;
        server 192.168.150.1:8081;
        server 192.168.150.1:8082;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    然后,修改对 tomcat 服务的反向代理,目标指向 tomcat 集群

    location /item {
        proxy_pass http://tomcat-cluster;
    }
    
    • 1
    • 2
    • 3

    重新加载 OpenResty

    nginx -s reload
    
    • 1

    1. 测试

    同时启动两台 tomcat 服务

    在这里插入图片描述
    在这里插入图片描述

    清空日志后,再次访问页面,可以看到不同id的商品,访问到了不同的 tomcat 服务 (这里直接贴资料给的图了)

    在这里插入图片描述

    在这里插入图片描述


    19.5.Redis 缓存预热


    Redis 缓存会面临冷启动问题

    • 冷启动:服务刚刚启动时,Redis 中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力。

    实际应用中的解决方法

    • 缓存预热:在实际开发中,我们可以利用大数据统计用户访问的热点数据,在项目启动时将这些热点数据提前查询并保存到 Redis 中。

    我们数据量较少,并且没有数据统计相关功能,目前可以在启动时将所有数据都放入缓存中。


    1. 利用 Docker 安装 Redis
    docker run --name redis -p 6379:6379 -d redis redis-server --appendonly yes
    
    • 1

    1. item-service 服务中引入 Redis 依赖
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-data-redisartifactId>
    dependency>
    
    • 1
    • 2
    • 3
    • 4

    1. 配置 Redis 地址
    spring:
      redis:
        host: 192.168.150.101
    
    • 1
    • 2
    • 3

    1. 编写初始化类

    缓存预热需要在项目启动时完成,并且必须是拿到 RedisTemplate 之后。

    这里我们利用 InitializingBean 接口来实现

    InitializingBean 可以在对象被 Spring 创建并且成员变量全部注入后执行。

    • afterPropertiesSet() 方法会在 Bean 创建完,@Autowired 注入成功后执行

    src/main/java/com/heima/item/config/RedisHandler.java

    package com.heima.item.config;
    
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.heima.item.pojo.Item;
    import com.heima.item.pojo.ItemStock;
    import com.heima.item.service.IItemService;
    import com.heima.item.service.IItemStockService;
    import org.springframework.beans.factory.InitializingBean;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    
    import java.util.List;
    
    @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);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52

    成功运行启动类后,发现 redis 中已经成功存储数据

    在这里插入图片描述


    19.6.优先查询 Redis 缓存


    现在,Redis 缓存已经准备就绪,我们可以再 OpenResty 中实现查询 Redis 的逻辑了。

    如下图红框所示

    在这里插入图片描述
    当请求进入 OpenResty 之后

    • 优先查询 Redis 缓存
    • 如果 Redis 缓存未命中,再查询 Tomcat

    19.6.1.封装 Redis 工具


    OpenResty 提供了操作 Redis 的模块,我们只要引入该模块就能直接使用。

    但是为了方便,我们将 Redis 操作封装到之前的 common.lua 工具库中。

    这需要修改 /usr/local/openresty/lualib/common.lua 文件


    1. 引入 Redis 模块,并初始化 Redis 对象
    -- 导入 redis
    local redis = require('resty.redis')
    
    -- 初始化 redis
    local red = redis:new()
    red:set_timeouts(1000, 1000, 1000)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    1. 封装函数,用来释放 Redis 连接,其实是放入连接池
    -- 关闭 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    1. 封装函数,根据 key 查询 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    1. 导出
    -- 将方法导出
    local _M = {  
        read_http = read_http,
        read_redis = read_redis
    }  
    return _M
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    完整的 common.lua

    -- 导入 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
    
    -- 查询 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
    
    -- 封装函数,发送 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,
        read_redis = read_redis
    }  
    return _M
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60

    19.6.2.实现 Redis 查询


    接下来,我们就可以去修改 item.lua 文件,实现对 Redis 的查询了。


    需求

    • 修改 item.lua,封装一个函数 read_data,实现先查询 Redis,若未命中,则再查询 Tomcat
    • 修改 item.lua,查询商品和库存时都调用 read_data 函数

    查询逻辑是

    • 根据 id 查询 Redis
    • 如果查询失败则继续查询 Tomcat
    • 将查询结果返回

    1. 修改 /usr/local/openresty/lua/item.lua 文件,添加一个查询函数
    -- 导入 common 函数库
    local common = require('common')
    
    local read_http = common.read_http
    local read_redis = common.read_redis
    
    -- 封装查询函数
    function read_data(key, path, params)
        -- 查询本地缓存
        local val = read_redis("127.0.0.1", 6379, key)
        -- 判断查询结果
        if not val then
            ngx.log(ngx.ERR, "redis 查询失败,尝试查询 http, key: ", key)
            -- redis 查询失败,去查询 http
            val = read_http(path, params)
        end
        -- 返回数据
        return val
    end
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    1. 而后修改商品查询、库存查询的业务

    在这里插入图片描述

    注意:资料里面的这个库存信息的存储变量不一致:stockJSONitemStockJSON(任选一个)

    算是一个坑,诸位注意一下。

    -- 根据 id 查询商品
    local itemJSON = read_data("item:id" .. id, "/item/" .. id, nil)
    
    -- 根据 id 查询商品库存
    local itemStockJSON = read_data("/item/stock/:id" .. id, "/item/stock/" .. id, nil)
    
    • 1
    • 2
    • 3
    • 4
    • 5

    1. 完整的 item.lua 代码
    -- 导入 common 函数库
    local common = require('common')
    
    local read_http = common.read_http
    local read_redis = common.read_redis
    
    -- 导入 cjson 库
    local cjson = require('cjson')
    
    -- 封装查询函数
    function read_data(key, path, params)
        -- 查询本地缓存
        local val = read_redis("127.0.0.1", 6379, key)
        -- 判断查询结果
        if not val then
            ngx.log(ngx.ERR, "redis 查询失败,尝试查询 http, key: ", key)
            -- redis 查询失败,去查询 http
            val = read_http(path, params)
        end
        -- 返回数据
        return val
    end
    
    -- 获取路径参数
    local id = ngx.var[1]
    
    -- 查询商品信息
    local itemJSON = read_data("item:id:" .. id,  "/item/" .. id, nil)
    
    -- 查询库存信息
    local itemStockJSON = read_data("item:stock:id:" .. id, "/item/stock/" .. id, nil)
    
    -- JSON 转化为 lua 的 table
    local item = cjson.decode(itemJSON)
    local stock = cjson.decode(itemStockJSON)
    
    -- 组合数据
    item.stock = stock.stock
    item.sold = stock.sold
    
    -- 把 item 序列化为 json 返回结果
    ngx.say(cjson.encode(item))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    • 这里记录我遇到的一个坑
    local itemJSON = read_data("item:id:" .. id,  "/item/" .. id, nil)
    
    • 1

    我在拼接字符串的时候,是完全按照视频来的,但是关闭 idea 启动的服务(其实就是 Tomcat),就一直报 500 错误。

    起初我以为是 ip 地址要用真实的地址,但依旧是 500 错误。

    无奈之下我打开了 /usr/local/openresty/nginx/log/error.log 文件

    2022/08/22 20:38:11 [error] 9111#9111: *319 [lua] common.lua:36: read_redis():
    	查询 Redis 数据为空, key = item:id10001, client: 这个是最开始的本机IP地址啦, 
    	server: localhost, request: "GET /api/item/10001 HTTP/1.0", 
    	host: "nginx-cluster", referrer: "http://localhost/item.html?id=10001"
    
    • 1
    • 2
    • 3
    • 4

    显然,问题出在这里:key = item:id10001。这是字符串拼接的问题。

    但是我的字符串拼接是没有问题的,但是日志里收到的 key 就是没有最后一个冒号。

    自始至终我都没有搞明白这个问题,于是乎决定润了。

    解决不了这个问题,我就从源头消灭这个问题。

    直接在 com/heima/item/config/RedisHandler.java 中给 key 加字符串的时候去掉最后一个冒号

    redisTemplate.opsForValue().set("item:id" + item.getId(), json);
    
    • 1

    当然,"item:stock:id": 同理,也是改为 "item:stock:id"

    redisTemplate.opsForValue().set("item:stock:id" + stock.getId(), json);
    
    • 1

    虽然没有解决问题,但好歹代码可以跑了,润了润了。

    究其原因应该是脚本语言的特性,相关错误可以通过相应的编辑器检查,比如 vscode。


    • 这里再记录我遇到的一个坑

    使用 nginx -s stop 之后,重新启动服务器时,访问 web 服务时无法成功浏览。

    虽然不太清楚之前做了啥操作,但这个问题还是要先解决的。

    使用 nginx -s reload 重新读取配置文件,报错

    nginx: [error] open() "/usr/local/nginx/logs/nginx.pid" failed (2: No such file or directory)
    
    • 1

    进到 logs 目录下发现没有 nginx.pid 文件

    使用如下命令即可再次生成 pid 文件,之后再次重载 nginx 即可运行。

    "/usr/local/openresty/nginx/sbin/nginx" -c "/usr/local/openresty/nginx/conf/nginx.conf"
    
    • 1

    19.7.Nginx 本地缓存


    现在,整个多级缓存中只差最后一环,也就是 nginx 的本地缓存了。

    在这里插入图片描述


    19.7.1.本地缓存 API


    OpenResty 为 Nginx 提供了 shard dict 的功能,可以在 nginx 内部的多个 worker 之间共享数据,实现缓存功能。

    如果你同时开启了多个 OpenResty,那不同的 nginx 内部的多个 worker 它们之间是没有办法共享的。


    1. 开启共享字典,在 nginx.conf 的 http 下添加配置
    # 共享字典,也就是本地缓存,名称叫做:item_cache,大小 150m
    lua_shared_dict item_cache 150m; 
    
    • 1
    • 2

    1. 操作共享字典
    -- 获取本地缓存对象
    local item_cache = ngx.shared.item_cache
    
    -- 存储, 指定 key、value、过期时间,单位 s,默认为 0 代表永不过期
    item_cache:set('key', 'value', 1000)
    
    -- 读取
    local val = item_cache:get('key')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    19.7.2.实现本地缓存查询


    需求

    • 修改 item.lua 中的 read_data 函数,优先查询本地缓存,未命中时再查询 Redis、Tomcat
    • 查询 Redis 或 Tomcat 成功后,将数据写入本地缓存,并设置有效期
    • 商品基本信息,有效期是 30 分钟
    • 库存信息,有效期是 1 分钟

    1. 修改 /usr/local/openresty/lua/item.lua 文件,修改 read_data 查询函数,添加本地缓存逻辑
    -- 导入共享词典,本地缓存
    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 val 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    1. 修改 item.lua 中查询商品和库存的业务,实现最新的 read_data 函数

    在这里插入图片描述
    其实就是多了缓存时间参数,过期后 nginx 缓存会自动删除,下次访问即可更新缓存。

    这里给商品基本信息设置超时时间为 30 分钟,库存为 1 分钟。

    因为库存更新频率较高,如果缓存时间过长,可能与数据库差异较大。


    1. 完整的 item.lua 文件
    -- 导入 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 val 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
    
    -- 把 item 序列化为 json 返回结果
    ngx.say(cjson.encode(item))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52

    20.缓存同步


    大多数情况下,浏览器查询到的都是缓存数据,如果缓存数据与数据库数据存在较大差异,可能会产生比较严重的后果。

    所以我们必须保证数据库数据、缓存数据的一致性,这就是缓存与数据库的同步。


    20.1.数据同步策略


    缓存数据同步的常见方式有三种:设置有效期、同步双写、异步通知。


    设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新

    • 优势:简单、方便
    • 缺点:时效性差,缓存过期之前可能不一致
    • 场景:更新频率较低,时效性要求低的业务

    同步双写:在修改数据库的同时,直接修改缓存

    • 优势:时效性强,缓存与数据库强一致
    • 缺点:有代码侵入,耦合度高;
    • 场景:对一致性、时效性要求较高的缓存数据

    异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据

    • 优势:低耦合,可以同时通知多个缓存服务
    • 缺点:时效性一般,可能存在中间不一致状态
    • 场景:时效性要求一般,有多个服务需要同步

    而异步实现又可以基于 MQ 或者 Canal 来实现

    1. 基于 MQ 的异步通知

    在这里插入图片描述

    解读

    • 商品服务完成对数据的修改后,只需要发送一条消息到 MQ 中。
    • 缓存服务监听 MQ 消息,然后完成对缓存的更新

    依然有少量的代码侵入。


    1. 基于 Canal 的通知

    在这里插入图片描述

    解读

    • 商品服务完成商品修改后,业务直接结束,没有任何代码侵入
    • Canal 监听 MySQL 变化,当发现变化后,立即通知缓存服务
    • 缓存服务接收到 canal 通知,更新缓存

    代码零侵入


    20.2.认识 Canal


    Canal [kə’næl],译意为水道/管道/沟渠

    canal 是阿里巴巴旗下的一款开源项目,基于 Java 开发。

    基于数据库增量日志解析,提供增量数据订阅&消费。

    GitHub 的地址:https://github.com/alibaba/canal

    Canal 是基于 MySQL 的主从同步来实现的


    MySQL 主从同步的原理如下

    在这里插入图片描述

    1. MySQL master 将数据变更写入二进制日志(binary log),其中记录的数据叫做 binary log events
    2. MySQL slavemasterbinary log events 拷贝到它的中继日志(relay log
    3. MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据

    而 Canal 就是把自己伪装成 MySQL 的一个 slave 节点,从而监听 masterbinary log 变化。

    再把得到的变化信息通知给 Canal 的客户端,进而完成对其它数据库的同步。

    在这里插入图片描述


    20.3.开启 MySQL 主从


    Canal 是基于 MySQL 的主从同步功能,因此必须先开启 MySQL 的主从功能才可以。

    这里以之前用 Docker 运行的 mysql 为例


    20.3.1.开启 binlog


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

    在这里插入图片描述


    修改文件

    vi /tmp/mysql/conf/my.cnf
    
    • 1

    添加内容

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

    配置解读

    • 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    20.3.2. 设置用户权限


    接下来添加一个仅用于数据同步的账户,出于安全考虑,这里仅提供对 heima 这个库的操作权限。

    create user canal@'%' IDENTIFIED by 'canal';
    
    GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%' identified by 'canal';
    
    FLUSH PRIVILEGES;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    重启 mysql 容器即可

    docker restart mysql
    
    • 1

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

    show master status;
    
    • 1

    在这里插入图片描述

    使用 Navicat 查看:点击“用户”,观察是否出现了 cannal 用户

    在这里插入图片描述


    20.4.安装Canal


    20.4.1.创建网络


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

    docker network create heima
    
    • 1

    让 mysql 加入这个网络

    docker network connect heima mysql
    
    • 1

    20.4.2.Docker 安装 Canal


    课前资料中提供了 canal 的镜像压缩包

    在这里插入图片描述


    大家可以上传到虚拟机,然后通过命令导入

    docker load -i canal.tar
    
    • 1

    然后运行命令创建 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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    说明


    • -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 是否启动成功

    docker logs -f canal
    
    • 1

    在这里插入图片描述


    此外,我们还可以进入 canal 容器内部查看相关信息

    docker exec -it canal bash
    
    • 1

    在这里插入图片描述

    在容器内部动态查看日志信息

    tail -f canal-server/logs/canal/canal.log 
    
    • 1

    在这里插入图片描述

    tail -f canal-server/logs/heima/heima.log 
    
    • 1

    在这里插入图片描述


    20.5.监听 Canal


    Canal 提供了各种语言的客户端,当 Canal 监听到 binlog 变化时,会通知 Canal 的客户端。

    在这里插入图片描述

    我们可以利用 Canal 提供的 Java 客户端,监听 Canal 通知消息。当收到变化的消息时,完成对缓存的更新。

    不过官方原生提供的客户端 api 是非常负载的,故这里我们会使用 GitHub 上的第三方开源的 canal-starter 客户端。

    地址:https://github.com/NormanGyllenhaal/canal-client

    与 SpringBoot 完美整合,自动装配,比官方提供的客户端 api 要简单好用的多。


    20.5.1.引入依赖


    <dependency>
        <groupId>top.javatoolgroupId>
        <artifactId>canal-spring-boot-starterartifactId>
        <version>1.2.1-RELEASEversion>
    dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    20.5.2.编写配置


    canal:
      destination: heima # canal 的集群名字,要与安装 canal 时设置的名称一致
      server: 192.168.150.101:11111 # canal 服务地址
    
    • 1
    • 2
    • 3

    20.5.3.修改 ltem 实体类


    Canal 推送给 canal-client 的是被修改的这一行数据(row)

    而我们引入的 canal-client 则会帮我们把行数据封装到 Item 实体类中。

    这个过程中需要知道数据库与实体的映射关系,要用到 JPA 的几个注解

    通过 @Id@Column、等注解完成 Item 与数据库表字段的映射

    com/heima/item/pojo/Item.java

    package com.heima.item.pojo;
    
    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableField;
    import com.baomidou.mybatisplus.annotation.TableId;
    import com.baomidou.mybatisplus.annotation.TableName;
    import lombok.Data;
    import org.springframework.data.annotation.Id;
    import org.springframework.data.annotation.Transient;
    
    import javax.persistence.Column;
    import java.util.Date;
    
    @Data
    @TableName("tb_item")
    public class Item {
        @TableId(type = IdType.AUTO)
        @Id// 标记表中的 id 字段
        private Long id;//商品 id
        @Column(name = "name")// 标记表中与属性名不一致的字段
        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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    20.5.4.编写监听器


    通过实现 EntryHandler 接口编写监听器,监听 Canal 消息。

    注意两点

    • 实现类通过 @CanalTable("tb_item") 指定监听的表信息
    • EntryHandler 的泛型是与表对应的实体类

    com/heima/item/canal/ItemHandler.java

    package com.heima.item.canal;
    
    import com.github.benmanes.caffeine.cache.Cache;
    import com.heima.item.config.RedisHandler;
    import com.heima.item.pojo.Item;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    import top.javatool.canal.client.annotation.CanalTable;
    import top.javatool.canal.client.handler.EntryHandler;
    
    @CanalTable("tb_item")//指定要监听的表
    @Component
    public class ItemHandler implements EntryHandler<Item> {//指定表关联的实体类,这里是 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());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    在这里对 Redis 的操作都封装到了 RedisHandler 这个对象中,是我们之前做缓存预热时编写的一个类,内容如下

    com/heima/item/config/RedisHandler.java

    package com.heima.item.config;
    
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.heima.item.pojo.Item;
    import com.heima.item.pojo.ItemStock;
    import com.heima.item.service.IItemService;
    import com.heima.item.service.IItemStockService;
    import org.springframework.beans.factory.InitializingBean;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    
    import java.util.List;
    
    @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);
            }
        }
    
        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(e);
            }
        }
    
        public void deleteItemById(Long id) {
            redisTemplate.delete("item:id:" + id);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65

    最后在照着视频进行测试的时候,idea 控制台可能会报错,不过没有关系,因为这里设置的缓存同步实在是太慢了。

    修改界面的信息没有发生变化,也没有关系,因为这里设置的缓存同步太慢了。

    等等就好了。


    20.5.5.小结


    在这里插入图片描述


    下一篇SpringCloud 微服务技术栈_高级篇⑤_可靠消息服务


  • 相关阅读:
    Simulink建模:LKA系统功能状态机建模
    推荐算法在商城系统实践
    Android11适配
    【C语言_线程pthread_互斥锁mutex_条件触发cond 之解析与示例 (开源)】.md updata:23/11/03
    SpringCloud:自定义skywalking链路追踪
    【leetcode】【2022/9/12】1608. 特殊数组的特征值
    win10电脑插入耳机,右边耳机声音比左边小很多
    消息队列基本原理和选型对比
    华为eNSP配置专题-路由策略的配置
    零售创新:社交媒体如何改变跨境电商游戏规则?
  • 原文地址:https://blog.csdn.net/yanzhaohanwei/article/details/126440484