Redis多级缓存

多级缓存

传统缓存的问题

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

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

多级缓存方案

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

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

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

在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器 ,而是一个编写业务的Web服务器

Tomcat服务将来也会部署为集群模式。

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

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

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

JVM进程缓存

导入商品案例

首先下载mysql相关文件

链接:百度网盘 请输入提取码 提取码:camy

1.安装MySQL

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

1.1.准备目录

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

复制代码
# 进入/tmp目录
cd /tmp
# 创建文件夹
mkdir mysql
# 进入mysql目录
cd mysql
1.2.运行命令

进入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=123456 \
 --privileged \
 -d \
 mysql:5.7.25
1.3.修改配置

在/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
1.4.重启

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

复制代码
docker restart mysql

Cammy/多级缓存的demo

初识Caffeine

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

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

接下来利用Caffeine框架来实现JVM进程缓存。

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

Caffeine的GitHub网址:GitHub - ben-manes/caffeine: A high performance caching library for Java

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

缓存使用的基本API:

复制代码
/*
    基本用法测试
 */
@Test
void testBasicOps() {
    // 构造cache对象
    Cache<String, String> cache = Caffeine.newBuilder().build();
    // 存数据
    cache.put("cf", "cammy");
    // 取数据
    String cf = cache.getIfPresent("cf");
    System.out.println("cf = " + cf);
    // 取数据,包含两个参数:
    //      参数一:缓存的key
    //      参数二:Lambda表达式,表达式参数就是缓存的key,方法体是查询数据库的逻辑
    // 优先根据key查询JVM缓存,如果未命中,则执行参数二的Lambda表达式
    String defaultCF = cache.get("defaultCF", ley -> {
        // 根据key去数据库查询数据
        return "cammy";
    });
    System.out.println("defaultCF = " + defaultCF);
}

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

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

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

复制代码
/*
    基于容量设置驱逐策略
 */
@Test
void testEvictByNum() throws InterruptedException {
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
            // 设置缓存大小上限为 1
            .maximumSize(1)
            .build();
    // 存数据
    cache.put("cf1", "cammy");
    System.out.println("cf1: " + cache.getIfPresent("cf1"));
    cache.put("cf2", "lee");
    System.out.println("cf2: " + cache.getIfPresent("cf2"));
    cache.put("cf3", "tom");
    System.out.println("cf3: " + cache.getIfPresent("cf3"));
    // 延迟10ms,给清理线程一点时间
    Thread.sleep(10L);
    System.out.println("---------------------");
    // 获取数据
    System.out.println("cf1: " + cache.getIfPresent("cf1"));
    System.out.println("cf2: " + cache.getIfPresent("cf2"));
    System.out.println("cf3: " + cache.getIfPresent("cf3"));
}

**基于时间:**设置缓存的有效时间

复制代码
/*
    基于时间设置驱逐策略
 */
@Test
void testEvictByTime() throws InterruptedException {
    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofSeconds(1)) // 设置缓存有效期为 1 秒
            .build();
    // 存数据
    cache.put("cf", "cammy");
    // 获取数据
    System.out.println("cf: " + cache.getIfPresent("cf"));
    // 休眠一会儿
    Thread.sleep(1200L);
    System.out.println("cf: " + cache.getIfPresent("cf"));
}

**基于引用:**设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用。

复制代码
// 略

实现JVM进程缓存

需求

利用Caffeine实现下列需求:

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

实现

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

在item-service的com.heima.item.config包下定义CaffeineConfig类:

复制代码
/**
 * @author : Cammy.Wu
 * Description : Caffeine配置类
 */

@Configuration
public class CaffeineConfig {

    @Bean
    public Cache<Long, Item> itemCache(){
        // 使用Caffeine创建一个缓存对象
        // 初始容量设置为100,这意味着缓存将预分配一定的空间以减少动态扩容的开销
        // 最大容量限制为10,000,超过这个大小时,缓存将根据Caffeine的默认淘汰策略开始移除条目
        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-service中的`com.heima.item.web`包下的ItemController类,添加缓存逻辑:

复制代码
@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));
}

Lua语法入门

初识Lua

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

Hello World

  1. 在Linux虚拟机的任意目录下,新建一个hello.lua文件
  1. 添加下面的内容
  1. 运行

变量和循环

数据类型

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

变量

Lua声明变量的时候,并不需要指定数据类型:

复制代码
-- 声明字符串
local str = 'hello'
-- 字符串拼接可以使用 ..
local str2 = 'hello' .. 'world'
-- 声明数字
local num = 21
-- 声明布尔类型
local flag = true
-- 声明数组 key为索引的 table
local arr = {'java', 'python', 'lua'}
-- 声明table,类似java的map
local map =  {name='Jack', age=21}

访问table:

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

循环

数组、table都可以利用for循环来遍历:

  • 遍历数组:

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

  • 遍历table:

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

条件控制、函数

函数

定义函数的语法:

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

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

条件控制

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

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

与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。 |

案例:自定义函数,打印table

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

实现多级缓存

安装OpenResty

安装OpenResty-CSDN博客

启动和运行

OpenResty底层是基于Nginx的,查看OpenResty目录的nginx目录,结构与windows中安装的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;
        }
    }
}

备注

在/usr/local/openresty/nginx/conf/nginx.conf文件添加以下:

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

编写item.lua文件:

在nginx目录创建文件夹:lua

复制代码
mkdir lua

在lua文件夹下新建item.lua

复制代码
touch lua/item.lua

内容如下:

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

获取请求参数

|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| 参数格式 | 参数示例 | 参数解析代码示例 |
| 路径占位符 | /item/1001 | -- 1. 正则表达式匹配: location ~ /item/(\d+) { content_by_lua_file lua/item.lua; } -- 2. 匹配到的参数会存入ngx.var数组中,可以用角标获取 local id = ngx.var[1] |
| 请求头 | id:1001 | -- 获取请求头,返回值是table类型 local headers = ngx.req.get_headers() |
| Get请求参数 | ?id=1001 | -- 获取GET请求参数,返回值是table类型 local getParams = ngx.req.get_uri_args() |
| Post表单参数 | id=1001 | -- 读取请求体 ngx.req.read_body() -- 获取POST表单参数,返回值是table类型 local postParams = ngx.req.get_post_args() |
| JSON参数 | {"id": 1001} | -- 读取请求体 ngx.req.read_body() -- 获取body中的json参数,返回值是string类型 local jsonBody = ngx.req.get_body_data() |

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

  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. 拼接ID并返回
  1. 重新加载并测试

    nginx -s reload

查询Tomcat

多级缓存需求

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

需要注意的是,我们的OpenResty是在虚拟机,Tomcat是在Windows电脑上。两者IP一定不要搞错了。

nginx内部发送Http请求

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

复制代码
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来对这个路径做反向代理:

封装http查询的函数

我们可以把http查询的请求封装为一个函数,放到OpenResty函数库中,方便后期使用。

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

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

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

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

复制代码
location /item {
  proxy_pass http://0.0.0.0:8081;
}

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

2)封装工具类

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

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

  1. /usr/local/openresty/lualib目录下创建common.lua文件:

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

  2. 在common.lua中封装http查询的函数

    -- 封装函数,发送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

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

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

3)实现商品查询

最后,我们修改/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)

这里查询到的结果是json字符串,并且包含商品、库存两个json字符串,页面最终需要的是把两个json拼接为一个json:

这就需要我们先把JSON变为lua的table,完成数据整合后,再转为JSON。

CJSON工具类

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

官方地址: GitHub - openresty/lua-cjson: Lua CJSON is a fast JSON encoding/parsing module for Lua

  • 引入cjson模块:

    local cjson = require "cjson"

  • 序列化:

    local obj = {
    name = 'jack',
    age = 21
    }
    -- 把 table 序列化为 json
    local json = cjson.encode(obj)

  • 反序列化:

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

实现Tomcat查询

复制代码
-- 引入自定义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(stockJSON)
-- 组合数据
item.stock = stock.stock
item.sold = stock.sold
-- 把item序列化为json返回结果
ngx.say(cjson.encode(item))

Tomcat集群的负载均衡

刚才的代码中,我们的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缓存生效。

2)实现

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

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

3)测试

启动两台tomcat服务:

同时启动:

清空日志后,再次访问页面,可以看到不同id的商品,访问到了不同的tomcat服务:

Redis缓存预热

Redis缓存会面临冷启动问题:

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

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

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

  1. 利用Docker安装Redis

    docker run --name redis -p 6379:6379 -d redis redis-server --appendonly yes

如果安装失败执行以下指令:

复制代码
touch /etc/docker/daemon.json
chmod 777 -R /etc/docker/daemon.json
vi /etc/docker/daemon.json

在daemon.json添加以下内容:

复制代码
{
  "registry-mirrors": ["https://docker-proxy.741001.xyz","https://registry.docker-cn.com"]
}

然后执行:

复制代码
systemctl daemon-reload
systemctl restart docker
  1. 在item-service服务中引入Redis依赖

    <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
  2. 配置Redis地址

    spring:
    redis:
    host: 0.0.0.0

  3. 编写初始化类

    @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缓存

Redis缓存已经准备就绪,我们可以再OpenResty中实现查询Redis的逻辑了。如下图红框所示:

当请求进入OpenResty之后:

  • 优先查询Redis缓存
  • 如果Redis缓存未命中,再查询Tomcat
封装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)

2)封装函数,用来释放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

3)封装函数,根据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

4)导出

复制代码
-- 将方法导出
local _M = {  
    read_http = read_http,
    read_redis = read_redis
}  
return _M
实现Redis查询

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

查询逻辑是:

  • 根据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

2)而后修改商品查询、库存查询的业务:

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

Nginx本地缓存

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

本地缓存API

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

1)开启共享字典,在nginx.conf的http下添加配置:

复制代码
 # 共享字典,也就是本地缓存,名称叫做:item_cache,大小150m
 lua_shared_dict item_cache 150m; 

2)操作共享字典:

复制代码
-- 获取本地缓存对象
local item_cache = ngx.shared.item_cache
-- 存储, 指定key、value、过期时间,单位s,默认为0代表永不过期
item_cache:set('key', 'value', 1000)
-- 读取
local val = item_cache:get('key')
实现本地缓存查询

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

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

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

缓存同步

数据同步策略

缓存数据同步的常见方式有三种:

  • 设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新
    • 优势:简单、方便
    • 缺点:时效性差,缓存过期之前可能不一致
    • 场景:更新频率较低,时效性要求低的业务
  • 同步双写:在修改数据库的同时,直接修改缓存
    • 优势:时效性强,缓存与数据库强一致
    • 缺点:有代码侵入,耦合度高;
    • 场景:对一致性、时效性要求较高的缓存数据
  • 异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据
    • 优势:低耦合,可以同时通知多个缓存服务
    • 缺点:时效性一般,可能存在中间不一致状态
    • 场景:时效性要求一般,有多个服务需要同步

基于MQ的异步通知:

基于Canal的异步通知:

以下主要使用Canal来进行操作。

Canal

认识Canal

Canal [kə'næl],译意为水道/管道/沟渠,canal是阿里巴巴旗下的一款开源项目,基于Java开发。基于数据库增量日志解析,提供增量数据订阅&消费。

GitHub的地址:GitHub - alibaba/canal: 阿里巴巴 MySQL binlog 增量订阅&消费组件

Canal是基于mysql的主从同步来实现的,MySQL主从同步的原理如下:

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

Canal就是把自己伪装成MySQL的一个slave节点,从而监听master的binary log变化。再把得到的变化信息通知给Canal的客户端,进而完成对其它数据库的同步。

安装Canal

安装Canal-CSDN博客

监听Canal

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

Canal提供了各种语言的客户端,当Canal监听到binlog变化时,会通知Canal的客户端。不过这里我们会使用GitHub上的第三方开源的canal-starter。地址:canal-starter

1.引入依赖
复制代码
<dependency>
  <groupId>top.javatool</groupId>
  <artifactId>canal-spring-boot-starter</artifactId>
  <version>1.2.1-RELEASE</version>
</dependency>
2.编写配置
复制代码
canal:
  destination: heima # canal的集群名字,要与安装canal时设置的名称一致
  server: 0.0.0.0:11111 # canal服务地址
3.编写监听器

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

编写监听器,监听Canal消息:

复制代码
@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());
    }
}

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

复制代码
@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);
    }
}
4.修改Item实体类

Canal推送给canal-client的是被修改的这一行数据(row),而我们引入的canal-client则会帮我们把行数据封装到Item实体类中。这个过程中需要知道数据库与实体的映射关系,要用到JPA的几个注解:

复制代码
@Data
@TableName("tb_item")
public class Item {
    @TableId(type = IdType.AUTO)
    @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;
}

总结

相关推荐
爱编程的喵8 分钟前
深入理解JavaScript原型机制:从Java到JS的面向对象编程之路
java·前端·javascript
on the way 12319 分钟前
行为型设计模式之Mediator(中介者)
java·设计模式·中介者模式
保持学习ing21 分钟前
Spring注解开发
java·深度学习·spring·框架
techzhi22 分钟前
SeaweedFS S3 Spring Boot Starter
java·spring boot·后端
禺垣1 小时前
区块链技术概述
大数据·人工智能·分布式·物联网·去中心化·区块链
异常君1 小时前
Spring 中的 FactoryBean 与 BeanFactory:核心概念深度解析
java·spring·面试
weixin_461259411 小时前
[C]C语言日志系统宏技巧解析
java·服务器·c语言
cacyiol_Z1 小时前
在SpringBoot中使用AWS SDK实现邮箱验证码服务
java·spring boot·spring
竹言笙熙1 小时前
Polarctf2025夏季赛 web java ez_check
java·学习·web安全
John Song1 小时前
Redis 集群批量删除key报错 CROSSSLOT Keys in request don‘t hash to the same slot
数据库·redis·哈希算法