多级缓存入门

文章目录

什么是多级缓存

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

存在下面的问题:

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

多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻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这样的语言

JVM进程缓存

环境准备

课前资料

安装MySQL

后期做数据同步需要用到MySQL的主从功能,所以在虚拟机中,利用Docker来运行一个MySQL容器

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

    java 复制代码
    # 进入/tmp目录
    cd /tmp
    # 创建文件夹
    mkdir mysql
    # 进入mysql目录
    cd mysql
  • 进入mysql目录后,执行下面的Docker命令:

    java 复制代码
    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
  • 在/tmp/mysql/conf目录添加一个my.cnf文件,作为mysql的配置文件:

    java 复制代码
    # 创建文件
    touch /tmp/mysql/conf/my.cnf
    
    #文件内容如下
    [mysqld]
    skip-name-resolve	#跳过域名简析
    character_set_server=utf8 #字符编码
    datadir=/var/lib/mysql	#指定数据库的目录
    server-id=1000		#数据库的服务ID
  • 重启容器: docker restart mysql

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

其中包含两张表:

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

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

导入Demo工程

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

项目结构如图所示:

其中的业务包括:

  • 分页查询商品
  • 新增商品
  • 修改商品
  • 修改库存
  • 删除商品
  • 根据id查询商品
  • 根据id查询库存

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

导入商品查询页面

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

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

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


我们找到课前资料的nginx目录:将其拷贝到一个非中文目录下,运行这个nginx服务

运行命令:start nginx.exe

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


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

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

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

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

文件内容:

java 复制代码
#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.1.10:8081;
    }
    server {
        listen       80;
        server_name  localhost;

		location /api {
            proxy_pass http://nginx-cluster;		# 监听/api路径,方向代理到nginx-cluster集群
        }

        location / {
            root   html;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

初识Caffeine

缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。我们把缓存分为两类:

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

本地进程缓存
Caffeine 是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine

Maven依赖:

java 复制代码
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

缓存使用的基本API:

java 复制代码
@Test
void testBasicOps() {
    // 构建cache对象
    Cache<String, String> cache = Caffeine.newBuilder().build();

    // 存数据
    cache.put("gf", "迪丽热巴");

    // 取数据
    String gf = cache.getIfPresent("gf");
    System.out.println("gf = " + gf);

    // 取数据,包含两个参数:
    // 参数一:缓存的key
    // 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是查询数据库的逻辑
    // 优先根据key查询JVM缓存,如果未命中,则执行参数二的Lambda表达式
    String defaultGF = cache.get("defaultGF", key -> {
        // 根据key去数据库查询数据
        return "柳岩";
    });
    System.out.println("defaultGF = " + defaultGF);
}

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

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

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

  • 基于容量:设置缓存的数量上限

    java 复制代码
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(1) // 设置缓存大小上限为 1
        .build();
  • 基于时间:设置缓存的有效时间

    java 复制代码
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
        .expireAfterWrite(Duration.ofSeconds(10)) 		 // 设置缓存有效期为 10 秒,从最后一次写入开始计时 
        .build();
  • 基于引用:设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用

Lua语法

初识Lua

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

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

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

第一个lua程序

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

  • 在Linux虚拟机的任意目录下,新建一个hello.lua文件

  • 添加下面的内容

    java 复制代码
    print("Hello World!")  
  • 运行

变量和循环

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

Lua的数据类型

数据类型 描述
nil 表示一个无效值(在条件表达式中相当于false)
boolean 包含两个值:false 和 true
number 表示双精度类型的浮点数
string 字符串由一对双引号或单引号来表示
function 由 C 或 Lua 编写的函数
table Lua 中的表(table)其实是一个"关联数组"(associative arrays),数组的索引可以是数字、字符串或表类型。在 Lua 里,table 的创建是通过"构造表达式"来完成,最简单构造表达式是{ },用来创建一个空表

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

声明变量

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

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

Lua中的table类型既可以作为数组,又可以作为Java中的map来使用。数组就是特殊的table,key是数组角标而已:

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

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

java 复制代码
-- 访问数组,lua数组的角标从1开始
print(arr[1])

Lua中的table可以用key来访问:

java 复制代码
-- 访问table
print(map['name'])
print(map.name)

循环

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

遍历数组:

java 复制代码
-- 声明数组 key为索引的 table
local arr = {'java', 'python', 'lua'}
-- 遍历数组
for index,value in ipairs(arr) do
    print(index, value) 
end

遍历普通table

java 复制代码
-- 声明map,也就是table
local map = {name='Jack', age=21}
-- 遍历table
for key,value in pairs(map) do
   print(key, value) 
end

条件控制、函数

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

函数

定义函数的语法:

java 复制代码
function 函数名( argument1, argument2..., argumentn)
    -- 函数体
    return 返回值
end

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

java 复制代码
function printArr(arr)
    for index, value in ipairs(arr) do
        print(value)
    end
end

条件控制

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

java 复制代码
if(布尔表达式)
then
   --[ 布尔表达式为 true 时执行该语句块 --]
else
   --[ 布尔表达式为 false 时执行该语句块 --]
end

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

操作符 描述
and 逻辑与操作符
or 逻辑或操作符
not 逻辑非操作符

多级缓存

多级缓存的实现离不开Nginx编程,而Nginx编程又离不开OpenResty

安装OpenResty

OpenResty® 是一个基于 Nginx的高性能 Web 平台,用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。具备下列特点:

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

  • 首先要安装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工具: yum install -y openresty-opm

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

  • 目录结构

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

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

  • 配置nginx的环境变量

    • 打开配置文件:

      java 复制代码
      vi /etc/profile
    • 在最下面加入两行

      java 复制代码
      export NGINX_HOME=/usr/local/openresty/nginx
      export PATH=${NGINX_HOME}/sbin:$PATH

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

    • 然后让配置生效:source /etc/profile

  • 启动:

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

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

    java 复制代码
    # 启动nginx
    nginx
    # 重新加载配置
    nginx -s reload
    # 停止
    nginx -s stop

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

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

    java 复制代码
    #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

OpenResty快速入门

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

其中:

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

反向代理流程

现在,商品详情页使用的是假的商品数据。不过在浏览器中,可以看到页面有发起ajax请求查询真实商品数据

这个请求如下:

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

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

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

OpenResty监听请求

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

  • 添加对OpenResty的Lua模块的加载

    修改/usr/local/openresty/nginx/conf/nginx.conf文件,在其中的http下面,添加下面代码:

    java 复制代码
    #lua 模块
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";
    #c模块     
    lua_package_cpath "/usr/local/openresty/lualib/?.so;;";  
  • 监听/api/item路径

    修改/usr/local/openresty/nginx/conf/nginx.conf文件,在nginx.conf的server下面,添加对/api/item这个路径的监听:

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

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

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

编写item.lua

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

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

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

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

    java 复制代码
    ngx.say('{"id":10001,"name":"SALSA AIR","title":"RIMOWA 21寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":17900,"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}')
  • 重新加载配置:nginx -s reload

请求参数处理

OpenResty提供了各种API用来获取不同类型的请求参数:


获取参数并返回

  • 在前端发起的ajax请求如图:

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

  • 获取商品id

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

    java 复制代码
    location ~ /api/item/(\d+) {
        # 默认的响应类型
        default_type application/json;
        # 响应结果由lua/item.lua文件来决定
        content_by_lua_file lua/item.lua;
    }
  • 拼接ID并返回

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

    java 复制代码
    -- 获取商品id
    local id = ngx.var[1]
    -- 拼接并返回
    ngx.say('{"id":' .. id .. ',"name":"SALSA AIR","title":"RIMOWA 21寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":17900,"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}')
  • 重新加载并测试:运行命令以重新加载OpenResty配置:nginx -s reload

    刷新页面可以看到结果中已经带上了ID:

查询Tomcat

拿到商品ID后,本应去缓存中查询商品信息,不过目前我们还未建立nginx、redis缓存。因此,这里我们先根据商品id去tomcat查询商品信息。我们实现如图部分:

发送http请求的API

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

java 复制代码
local resp = ngx.location.capture("/path",{
    method = ngx.HTTP_GET,   -- 请求方式
    args = {a=1,b=2},  -- get方式传参数
})

返回的响应内容包括:

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

注意:这里的path是路径,并不包含IP和端口。这个请求会被nginx内部的server监听并处理

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

java 复制代码
location /path {
     # 这里是windows电脑的ip和Java服务端口,需要确保windows防火墙处于关闭状态
     proxy_pass http://192.168.1.102:8081; 
}

封装http工具

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

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

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

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

    java 复制代码
    location /path {
        # 这里是windows电脑的ip和Java服务端口,需要确保windows防火墙处于关闭状态
        proxy_pass http://192.168.1.102:8081; 
    }

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

  • 封装工具类

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

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

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

    内容如下:

    java 复制代码
    -- 封装函数,发送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

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

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

  • 实现商品查询

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

    java 复制代码
    -- 引入自定义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)

CJSON工具类

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

  • 引入cjson模块:local cjson = require "cjson"

  • 序列化:

    java 复制代码
    local obj = {
        name = 'jack',
        age = 21
    }
    -- 把 table 序列化为 json
    local json = cjson.encode(obj)
  • 反序列化

    java 复制代码
    local json = '{"name": "jack", "age": 21}'
    -- 反序列化 json为 table
    local obj = cjson.decode(json);
    print(obj.name)

实现Tomcat查询

java 复制代码
-- 导入common函数库  
local common = require('common')  
local read_http = common.read_http  
local cjson = require('cjson')  
  
-- 获取路径参数  
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  
  
-- 返回JSON数据
ngx.say(cjson.encode(item)) 
相关推荐
ashane13141 小时前
Redis 哨兵集群(Sentinel)与 Cluster 集群对比
redis
Y第五个季节2 小时前
Redis - HyperLogLog
数据库·redis·缓存
Justice link3 小时前
企业级NoSql数据库Redis集群
数据库·redis·缓存
爱的叹息6 小时前
Spring Boot 集成Redis 的Lua脚本详解
spring boot·redis·lua
morris13113 小时前
【redis】redis实现分布式锁
数据库·redis·缓存·分布式锁
爱的叹息15 小时前
spring boot集成reids的 RedisTemplate 序列化器详细对比(官方及非官方)
redis
weitinting16 小时前
Ali linux 通过yum安装redis
linux·redis
纪元A梦17 小时前
Redis最佳实践——首页推荐与商品列表缓存详解
数据库·redis·缓存
爱的叹息1 天前
Java 连接 Redis 的驱动(Jedis、Lettuce、Redisson、Spring Data Redis)分类及对比
java·redis·spring
松韬1 天前
Spring + Redisson:从 0 到 1 搭建高可用分布式缓存系统
java·redis·分布式·spring·缓存