多级缓存
缓存的作用: 减轻数据库的压力,缩短服务响应的时间,提高服务的并发能力。虽然Redis的并发效果已经很好了,但是依然有上限,随着互联网的发展,用户量越来越大,并发量非常庞大时,仅仅靠Redis不能满足庞大的并发需求,我们下面学习的多级缓存正式用来应对亿级流量的并发
注意本篇内容非常多,而且是同一个实验,所以最好是跟着从上到下,不要跳过
1. 多级缓存的意义
传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库,存在下面的问题:
1、请求要经过Tomcat处理,Tomcat的并发能力其实是不如Redis,导致Tomcat的性能成为整个系统的瓶颈
2、Redis缓存失效时,会对数据库产生冲击
多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能
在上图中,我们需要学习如何在Tomcat编写进程缓存,还需要学习如何在Nginx内部使用Lua语言进行编程,然后学习Nginx本地缓存、Redis缓存、Tomcat缓存等多级缓存,最后学习数据库与缓存之间的同步策略。下面会逐步学习
2. JVM进程缓存案例搭建
先学习 ‘如何在Tomcat编写进程缓存’ ,也就是 JVM进程缓存,我们会通过一个案例进行学习
为了演示多级缓存,我们先导入一个商品管理的案例,其中包含商品的CRUD功能。我们将来会给查询商品添加多级缓存
【部署mysql容器,虽然前面在学习 ‘实用篇-Docker容器’ 学过,但是为避免出现各种问题,下面还是需要再做一次】
删除之前做过的mysql容器
docker rm -f mysql # 删除在docker里面的mysql容器
rm -rf /tmp/mysql # 删除mysql的挂载数据
第一步: 创建三个目录,分别是/tmp/mysql/data、/tmp/mysql/conf,/tmp/mysql/logs
mkdir -p /tmp/mysql/{data,conf,logs}
cd /tmp/mysql && ls
第二步: 在docker部署mysql容器。由于MySQL文件较大,拉取耗时较长,所以下面提供了下载链接,下载到自己Windows电脑,
然后传到虚拟机/tmp/mysql目录
mysql.tar文件快速下载: https://cowtransfer.com/s/b047088bc7f048
第三步: 将mysql.tar文件,上传到虚拟机/tmp/mysql目录,再通过前面学的’镜像命令-导入镜像到docker’,里面学过load命令
cd /tmp/mysql
docker load -i mysql.tar
第四步: 在/tmp/mysql/conf目录,创建一个文件叫hmy.cnf,写入如下
cd /tmp/mysql/conf
touch hmy.cnf
vi hmy.cnf
[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000
第五步: 到此准备工作已结束,正式开始挂载。在Docker大容器(也叫Docker主机、宿主机)使用MySQL镜像来创建并运行MySQL容器
下面命令全部复制执行,注意密码为123
cd /tmp/mysql
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
# docker run: 创建并运行容器
# --name: 给容器起一个名字,例如叫mysql
# -e: MySQL登录密码
# -p: 端口号
# -v: [挂载宿主机的配置文件]:[mysql容器的配置文件]
# -v: [挂载宿主机的用于存放数据的文件]:[mysql容器的用于存放数据的文件]
# -d: 后台运行容器
# mysql:5.7.25: 镜像名称,由于我们的mysql镜像是这个版本,所以镜像名称需要带上镜像版本号
第六步: 去连接一下这个mysql容器里面的mysql服务,查看连接是否正常
第七步: 下次运行这个mysql容器,只要执行如下即可
systemctl start docker # 启动docker服务
docker restart mysql #启动mysql容器
第八步: 在mysql数据库创建名为JvmDataShop的库,并在JvmDataShop库执行创建tb_item商品表、tb_item_stock商品库存表的SQL语句
create database if not exists JvmDataShop;
use JvmDataShop;
CREATE TABLE `tb_item` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品id',
`title` varchar(264) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '商品标题',
`name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '商品名称',
`price` bigint(20) NOT NULL COMMENT '价格(分)',
`image` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '商品图片',
`category` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '类目名称',
`brand` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '品牌名称',
`spec` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '规格',
`status` int(1) NULL DEFAULT 1 COMMENT '商品状态 1-正常,2-下架,3-删除',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `status`(`status`) USING BTREE,
INDEX `updated`(`update_time`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 50002 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '商品表' ROW_FORMAT = COMPACT;
INSERT INTO `tb_item` VALUES (10001, 'RIMOWA 21寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4', 'SALSA AIR', 16900, 'https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp', '拉杆箱', 'RIMOWA', '{\"颜色\": \"红色\", \"尺码\": \"26寸\"}', 1, '2019-05-01 00:00:00', '2019-05-01 00:00:00');
INSERT INTO `tb_item` VALUES (10002, '安佳脱脂牛奶 新西兰进口轻欣脱脂250ml*24整箱装*2', '脱脂牛奶', 68600, 'https://m.360buyimg.com/mobilecms/s720x720_jfs/t25552/261/1180671662/383855/33da8faa/5b8cf792Neda8550c.jpg!q70.jpg.webp', '牛奶', '安佳', '{\"数量\": 24}', 1, '2019-05-01 00:00:00', '2019-05-01 00:00:00');
INSERT INTO `tb_item` VALUES (10003, '唐狮新品牛仔裤女学生韩版宽松裤子 A款/中牛仔蓝(无绒款) 26', '韩版牛仔裤', 84600, 'https://m.360buyimg.com/mobilecms/s720x720_jfs/t26989/116/124520860/644643/173643ea/5b860864N6bfd95db.jpg!q70.jpg.webp', '牛仔裤', '唐狮', '{\"颜色\": \"蓝色\", \"尺码\": \"26\"}', 1, '2019-05-01 00:00:00', '2019-05-01 00:00:00');
INSERT INTO `tb_item` VALUES (10004, '森马(senma)休闲鞋女2019春季新款韩版系带板鞋学生百搭平底女鞋 黄色 36', '休闲板鞋', 10400, 'https://m.360buyimg.com/mobilecms/s720x720_jfs/t1/29976/8/2947/65074/5c22dad6Ef54f0505/0b5fe8c5d9bf6c47.jpg!q70.jpg.webp', '休闲鞋', '森马', '{\"颜色\": \"白色\", \"尺码\": \"36\"}', 1, '2019-05-01 00:00:00', '2019-05-01 00:00:00');
INSERT INTO `tb_item` VALUES (10005, '花王(Merries)拉拉裤 M58片 中号尿不湿(6-11kg)(日本原装进口)', '拉拉裤', 38900, 'https://m.360buyimg.com/mobilecms/s720x720_jfs/t24370/119/1282321183/267273/b4be9a80/5b595759N7d92f931.jpg!q70.jpg.webp', '拉拉裤', '花王', '{\"型号\": \"XL\"}', 1, '2019-05-01 00:00:00', '2019-05-01 00:00:00');
CREATE TABLE `tb_item_stock` (
`item_id` bigint(20) NOT NULL COMMENT '商品id,关联tb_item表',
`stock` int(10) NOT NULL DEFAULT 9999 COMMENT '商品库存',
`sold` int(10) NOT NULL DEFAULT 0 COMMENT '商品销量',
PRIMARY KEY (`item_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = COMPACT;
INSERT INTO `tb_item_stock` VALUES (10001, 99996, 3219);
INSERT INTO `tb_item_stock` VALUES (10002, 99999, 54981);
INSERT INTO `tb_item_stock` VALUES (10003, 99999, 189);
INSERT INTO `tb_item_stock` VALUES (10004, 99999, 974);
INSERT INTO `tb_item_stock` VALUES (10005, 99999, 18649);
第九步: 下载item-service.zip,解压后是item-service文件夹,是一个项目工程,用idea打开,并且修改application.yml的数据库连接信息
https://cowtransfer.com/s/353ec9dc3dd54d
第十步: 运行ItemApplication引导类
访问主页: http://localhost:8081
访问商品: http://localhost:8081/item/10001
访问商品库存: http://localhost:8081/item/stock/10001
第十一步: 部署Nginx反向代理服务。
我们希望在查询接口增加缓存业务。设计如下。把item.html页面放在Nginx反向代理服务器,用户请求商品页面时,就把这个item.html页面返回给用户
(1) 下载nginx-1.18.0.zip,下载后解压到D盘,下载链接如下
https://cowtransfer.com/s/9d4e834d28e345
(2) 修改nginx.conf文件
(3) win+r,输入cmd并回车,然后输入下面的命令启动Nginx
d:
cd nginx-1.18.0
start nginx.exe
(3)浏览器访问Nginx
localhost
(4) 浏览器访问Nginx的item.html页面。目前这个页面的数据是写死的假数据,后续我们会使用缓存向服务器查询数据,然后把请求到的数据渲染到页面
http://localhost/item.html?id=10001
3. JVM进程缓存案例实现
上面我们已经部署了必要的环境,请确保你的环境正常启动,然后我们就开始业务需求的实现
(1) 启动mysql
systemctl start docker # 启动docker服务
docker restart mysql #启动mysql容器
(2) 启动Nginx
d:
cd nginx-1.18.0
start nginx.exe
我们下面会使用Caffeine来实现 JVM进程缓存案例
3. JVM进程缓存案例实现
上面我们已经部署了必要的环境,请确保你的环境正常启动,然后我们就开始业务需求的实现
(1) 启动mysql
systemctl start docker # 启动docker服务
docker restart mysql #启动mysql容器
(2) 启动Nginx
d:
cd nginx-1.18.0
start nginx.exe
我们下面会使用Caffeine来实现 JVM进程缓存案例
一、初始Caffeine
Caffeine是一个缓存技术相关的库,读 kě fì,翻译过来就是咖啡因
【本地进程缓存】
缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力
我们把缓存分为如下两类
1、分布式(往往用在集群的环境下)缓存,例如Redis
- 优点:存储容量更大、可靠性更好、可以在集群间共享
- 缺点:访问缓存有网络开销
- 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享
2、进程本地缓存,例如HashMap、GuavaCache
- 优点:读取本地内存,没有网络开销,速度更快
- 缺点:存储容量有限、可靠性较低、无法共享
- 场景:性能要求较高,缓存数据量较小
Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine
GitHub地址:https://github.com/ben-manes/caffeine
Caffeine提供了三种缓存驱逐策略,也就是缓存过期策略:
1、基于容量:设置缓存的数量上限
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(1) // 设置缓存大小上限为 1
.build();
2、基于时间:设置缓存的有效时间
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(10)) // 设置缓存有效期为 10 秒,从最后一次写入开始计时
.build();
3、基于引用:设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用
在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐
二、Caffeine基本演示
用idea打开item-service项目,在item-service项目进行Caffeine基本API的使用
第一步(已做可跳过): 在item-service项目的pom.xml添加如下
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
第二步(已做可跳过): 打开CaffeineTest类,写入如下
package com.heima.item.test;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.junit.jupiter.api.Test;
import java.time.Duration;
public class CaffeineTest {
/*
基本用法测试
*/
@Test
void testBasicOps() {
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder().build();
// 存数据
cache.put("gf", "迪丽热巴");
// 取数据,不存在则返回null
String gf = cache.getIfPresent("gf");
System.out.println("gf = " + gf);
// 取数据,不存在则去数据库查询
String defaultGF = cache.get("defaultGF", key -> {
// 这里可以去数据库根据 key查询value
return "柳岩";
});
System.out.println("defaultGF = " + defaultGF);
}
/*
基于大小设置驱逐策略:
*/
@Test
void testEvictByNum() throws InterruptedException {
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
// 设置缓存大小上限为 1
.maximumSize(1)
.build();
// 存数据
cache.put("gf1", "柳岩");
cache.put("gf2", "范冰冰");
cache.put("gf3", "迪丽热巴");
// 延迟10ms,给清理线程一点时间
Thread.sleep(10L);
// 获取数据
System.out.println("gf1: " + cache.getIfPresent("gf1"));
System.out.println("gf2: " + cache.getIfPresent("gf2"));
System.out.println("gf3: " + cache.getIfPresent("gf3"));
}
/*
基于时间设置驱逐策略:
*/
@Test
void testEvictByTime() throws InterruptedException {
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(3)) // 设置缓存有效期为 3 秒
.build();
// 存数据
cache.put("gf", "柳岩");
// 获取数据
System.out.println("gf: " + cache.getIfPresent("gf"));
// 休眠一会儿
Thread.sleep(1200L);
System.out.println("gf: " + cache.getIfPresent("gf"));
}
}
第三步: 运行CaffeineTest类的testBasicOps方法,测试在缓存中存取数据的基本用法
第四步: 运行CaffeineTest类的testEvictByNum方法,测试给缓存设置一个过期策略,例如当缓存缓存上限超过1,那么旧缓存就会被清理
第五步: 运行CaffeineTest类的testEvictByTime方法,测试给缓存设置一个过期策略,例如当缓存缓存时间超过3秒,那么旧缓存就会被清理
三、Caffeine实现进程缓存
案例: 实现商品的查询的本地进程缓存,利用Caffeine实现下列需求
1、给根据id查询商品的业务添加缓存,缓存未命中时查询数据库
2、给根据id查询商品库存的业务添加缓存,缓存未命中时查询数据库
3、缓存初始大小为100
4、缓存上限为10000
具体操作如下
第一步(已做可跳过): 在item-service项目的pom.xml添加如下
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
第二步: 在item-service项目的com.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;
/**
* @author 35238
* @date 2023/6/26 0026 22:41
*/
@Configuration
public class CaffeineConfig {
@Bean
//下面的Cache接口是caffeine提供的
public Cache<Long, Item> itemCache(){
return Caffeine.newBuilder()
//缓存初始大小为100
.initialCapacity(100)
//缓存上限为10000,写成10_000会被自动处理成10000
.maximumSize(10_000)
.build();
}
@Bean
//下面的Cache接口是caffeine提供的
public Cache<Long, ItemStock> stockCache(){
return Caffeine.newBuilder()
//缓存初始大小为100
.initialCapacity(100)
//缓存上限为10000,写成10_000会被自动处理成10000
.maximumSize(10_000)
.build();
}
}
第三步: 查看ItemController类,分析一下我们要给什么请求加缓存
第四步: 在ItemController类添加如下。也就是引入依赖,修改findById、findStockById方法
//注入bean,用来做商品缓存
@Autowired
private Cache<Long,Item> itemCache;
//注入bean,用来做库存缓存
@Autowired
private Cache<Long,ItemStock> stockCache;
@GetMapping("/{id}")
public Item findById(@PathVariable("id") Long id){
//给这个请求添加缓存功能。使用get,实现自动查缓存,缓存没查到查数据库
//get的第一个参数是根据id优先查缓存,第二个参数是如果缓存没查到就根据Lambda表达式来查数据库
return itemCache.get(id,key -> itemService.query()
.ne("status", 3).eq("id", key)
.one());
}
@GetMapping("/stock/{id}")
public ItemStock findStockById(@PathVariable("id") Long id){
//给这个请求添加缓存功能。使用get,实现自动查缓存,缓存没查到查数据库
//get的第一个参数是根据id优先查缓存,第二个参数是如果缓存没查到就根据Lambda表达式来查数据库
return stockCache.get(id,key -> stockService.getById(key));
}
第五步: 重新运行ItemApplication引导类,浏览器测试商品功能
http://localhost:8081/item/10001
第六步: 重新运行ItemApplication引导类,浏览器测试库存功能
http://localhost:8081/item/stock/10001
4. Lua语法
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能
官网:https://www.lua.org/。Lua读 lū ǎ
一、安装Lua
第一步: 打开官网
第二步: 在linux终端输入如下。注意CentOS自带Lua环境
curl -R -O http://www.lua.org/ftp/lua-5.4.6.tar.gz
tar zxf lua-5.4.6.tar.gz
cd lua-5.4.6
make all test
二、初识Lua
第一步: 在Linux虚拟机的/root目录下,新建LuaDemo目录,里面新建一个hello.lua文件
cd /root
mkdir LuaDemo
cd LuaDemo && touch hello.lua
vi hello.lua
第二步: 在hello.lua文件里面写入如下
print("Hello World")
第三步: 运行hello.lua文件
cd /root/LuaDemo
lua hello.lua
三、变量和循环
【数据类型】
数据类型 | 描述 |
---|---|
nil | 这个最简单,只有值nil属于该类,表示一个无效值(在条件表达式中相当于false) |
boolean | 包含两个值:false和true |
number | 表示双精度类型的实浮点数 |
string | 字符串由一对双引号或单引号来表示 |
function | 由 C 或 Lua 编写的函数 |
table | Lua 中的表(table)其实是一个"关联数组"(associative arrays),数组的索引可以是数字、字符串或表类型。在 Lua 里,table 的创建是通过"构造表达式"来完成,最简单构造表达式是{},用来创建一个空表 |
可以利用type函数测试给定变量或者值的类型:
【变量】
Lua声明变量的时候,并不需要指定数据类型,也可以去掉local表示全局:
-- 声明字符串
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)
【循环】
用ipairs遍历数组:
-- 声明数组 key为索引的 table
local arr = {'java', 'python', 'lua'}
-- 遍历数组
for index,value in ipairs(arr) do
print(index, value)
end
用pairs遍历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
例如,定义一个函数,用来打印数组:
function printArr(arr)
for index, value in ipairs(arr) do
print(value)
end
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,当参数为nil时,打印错误信息。具体操作如下
第一步: 在/root/LuaDemo目录新建FunctionDemo.lua文件,写入如下
cd /root/LuaDemo
touch FunctionDemo.lua
vi FunctionDemo.lua
local function printXxx(arr)
if (not arr) then
print('数组不能为空!')
return nil
end
for i, val in ipairs(arr) do
print(val)
end
end
local arr1 = {100,200,300}
printXxx(arr1)
printXxx(nil)
多级缓存的最终实现
一、安装OpenResty
OpenResty是一个基于 Nginx的高性能 Web 平台,用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。具备下列特点:
1、具备Nginx的完整功能
2、基于Lua语言进行扩展,集成了大量精良的 Lua 库、第三方模块
3、允许使用Lua自定义业务逻辑、自定义库
官方网站: https://openresty.org/cn/。在下面的操作中,你完全可以把OpenResty认为是Nginx,并且OpenResty是基于Nginx实现的
第一步: 首先要安装OpenResty的依赖开发库,执行命令:
yum install -y pcre-devel openssl-devel gcc --skip-broken
第二步: 安装OpenResty仓库。在你的 CentOS 系统中添加 openresty 仓库,这样就可以便于未来安装或更新软件包
yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
如果提示说命令不存在,则先运行:
yum install -y yum-utils
第三步: 安装OpenResty软件包
yum install -y openresty
第四步: 安装opm工具。opm是OpenResty的一个管理工具,可以帮助我们安装一个第三方的Lua模块
yum install -y openresty-opm
第五步: 目录结构。默认情况下,OpenResty安装的目录是:/usr/local/openresty
注意其中的nginx目录,OpenResty就是在Nginx基础上集成了一些Lua模块
cd /usr/local/openresty
ll
或者直接执行下面那条命令
ll /usr/local/openresty
第七步: 配置nginx的环境变量,以后就能在任意目录启动和运行
打开配置文件:
vi /etc/profile
在最下面加入两行:
export NGINX_HOME=/usr/local/openresty/nginx
export PATH=${NGINX_HOME}/sbin:$PATH
第八步: 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;
}
}
}
第九步: 启动和运行。由于OpenResty底层是基于Nginx的,查看OpenResty目录的nginx目录,结构与windows中安装的nginx基本一致
所以运行方式与nginx基本一致:
# 启动nginx,就是启动OpenResty
nginx
# 重新加载配置
nginx -s reload
# 停止
nginx -s stop
# 查看nginx运行状态
ps -ef | grep nginx
第十步: 然后访问页面:http://192.168.127.180:8081,注意ip地址替换为你自己的虚拟机IP:
二、OpenResty快速入门
商品详情页面目前展示的是假数据,在浏览器的控制台可以看到查询商品信息的请求:
而这个请求最终被反向代理到虚拟机的OpenResty集群:
需求:实现商品详情页数据查询。在OpenResty中接收这个请求,并返回一段商品的假数据
第一步: 在nginx.conf的http下面,添加对OpenResty的Lua模块的加载
vi /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;;";
第二步: 在nginx.conf的server下面,添加对/api/item这个路径的监听
vi /usr/local/openresty/nginx/conf/nginx.conf
location /api/item {
# 响应类型,这里返回json
default_type application/json;
# 响应数据由 lua/item.lua这个文件来决定
content_by_lua_file lua/item.lua;
}
第三步: 在 /usr/local/openresty/nginx 目录创建lua文件夹,在lua文件夹新建item.lua文件
cd /usr/local/openresty/nginx
mkdir lua
cd lua && touch item.lua
第四步: 在item.lua文件写入如下
-- 返回假数据,这里的ngx.say()函数,就是写数据到Response中
ngx.say('{"id":10001,"name":"SALSA AIR","title":"RIMOWA 999寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":99999,"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.conf文件的命令,不要粘贴
nginx
nginx -s reload
第六步: 确保你Windows的Nginx正在运行。win+r,输入cmd并回车,然后输入下面的命令启动Nginx
d:
cd nginx-1.18.0
start nginx.exe
确保你Windows的Nginx配置正确
第七步: 不需要启动idea,不需要启动docker里面的mysql容器。浏览器访问如
localhost/item.html?id=10001
三、请求参数处理
刚刚我们在OpenResty快速入门中,学习了如何把假数据返回给用户,但是在实际开发中,我们需要根据不同请求去查询不同的商品数据,那么我们就需要先得到不同请求的参数,如何得到,下面就来学习请求参数处理,在OpenResty里面获取用户的请求参数,也就是如何在请求路径里获取参数
OpenResty提供了各种API用来获取不同类型的请求参数,如下表
参数格式 | 参数示例 | 参数解析代码示例 |
---|---|---|
路径占位符 | /item/1001 | ①正则表达式匹配: location ~ /item/(\d+) { content_by_lua_file lua/item.lua;}; ②匹配到的参数会存入ngx.var数组中,可以用角标获取,例如local id = ngx.var[1] |
请求头 | id: 1001 | 获取请求头,返回值是table类型,例如local headers = ngx.req.get_headers() |
Get请求参数 | ?id=10 | 获取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到后台:
需求: 在OpenResty中接收这个请求,并获取路径中的id信息,拼接到结果的json字符串中返回
第一步: 在nginx.conf的server下面,给/api/item监听路径添加正则表达式,~表示正则表达式
我把整个代码都放出来了,不要复制,只是在原来的基础上修改了一点点,手打就行
vi /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;
# 加载lua模块
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
# 加载c模块
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
server {
listen 8081;
server_name localhost;
location ~ /api/item/(\d+) {
# 响应类型,这里返回json
default_type application/json;
# 响应数据由 lua/item.lua这个文件来决定
content_by_lua_file lua/item.lua;
}
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/lua 目录下的item.lua文件,修改为如下
vi /usr/local/openresty/nginx/lua/item.lua
-- 获取路径参数
local id = ngx.var[1]
-- 返回结果
ngx.say('{"id":'..id ..',"name":"SALSA AIR","title":"RIMOWA 999寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4","price":99999,"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(也就是OpenResty)的配置。执行报错的话,就自己把第一步的代码手打,不要直接复制粘贴
nginx
nginx -s reload
第四步: 确保你Windows的Nginx正在运行。win+r,输入cmd并回车,然后输入下面的命令启动Ngin
d:
cd nginx-1.18.0
start nginx.exe
确保你Windows的Nginx配置正确
第五步: 不需要启动idea,不需要启动docker里面的mysql容器。浏览器访问如下
http://localhost/item.html?id=10002 访问本地的页面 在这个页面会通过ajax访问具体的接口 然后通过Nginx反向代理到虚拟机里的OpenResty
四、查询Tomcat
【nginx内部发送Http请求】
nginx提供了内部API用以发送http请求:
local resp = ngx.location.capture("/path",{
method = ngx.HTTP_GET, -- 请求方式
args = {a=1,b=2}, -- get方式传参数
body = "c=3&d=4" -- post方式传参数
})
返回的响应内容包括如下:
1、resp.status:响应状态码
2、resp.header:响应头,是一个table
3、resp.body:响应体,就是响应数据
注意:上面的的path是路径,并不包含IP和端口。这个请求会被nginx内部的server监听并处理。但是我们希望这个请求发送到Tomcat服务器,所以还需要编写一个server来对这个路径做反向代理:
location /path {
# 这里是windows电脑的ip和Java服务端口,需要确保windows防火墙处于关闭状态
proxy_pass http://192.168.127.1:8081;
}
【封装http查询的函数】
我们可以把http查询的请求封装为一个函数,放到OpenResty函数库中,方便后期使用
在/usr/local/openresty/lualib目录下创建common.lua文件,在common.lua文件写入如
vi /usr/local/openresty/lualib/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
【使用Http函数查询数据】
我们刚才已经把http查询的请求封装为一个函数,放到OpenResty函数库中,接下来就可以使用这个库了
把 /usr/local/openresty/nginx/lua 目录的item.lua文件,修改为如下
vi /usr/local/openresty/nginx/lua/item.lua
-- 引入自定义工具模块
local common = require("common")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结果处理】
OpenResty提供了一个cjson的模块用来处理JSON的序列化和反序列化。
官方地址: https://github.com/openresty/lua-cjson/
引入cjson模块:
local cjson = require "cjson"
序列化:
local obj = {
name = 'jack',
age = 21
}
local json = cjson.encode(obj)
反序列化:
local json = '{"name": "jack", "age": 21}'
-- 反序列化
local obj = cjson.decode(json);
print(obj.name)
案例: 获取请求路径中的商品id信息,根据id向Tomcat查询商品信息,注意这里要修改item.lua,满足下面的需求
1、获取请求参数中的id根据id
2、向Tomcat服务发送请求,查询商品信息
3、根据id向Tomcat服务发送请求,查询库存信息
4、组装商品信息、库存信息,序列化为JSON格式并返回
第一步: 在nginx.conf的server下面,再添加一个location
我把整个代码都放出来了,不要复制,只是在原来的基础上修改了一点点,手打就行
vi /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;
# 加载lua模块
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
# 加载c模块
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
server {
listen 8081;
server_name localhost;
# 给/item请求地址做反向代理,ip地址是Windows的地址
# 作用是用户请求/item的时候,实际请求的是windows的idea的tomcat
location /item {
proxy_pass http://192.168.127.1:8081;
}
location ~ /api/item/(\d+) {
# 响应类型,这里返回json
default_type application/json;
# 响应数据由 lua/item.lua这个文件来决定
content_by_lua_file lua/item.lua;
}
location / {
root html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
第二步: 在/usr/local/openresty/lualib目录下创建common.lua文件,在common.lua文件写入如下
cd /usr/local/openresty/lualib
touch common.lua
vi 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
第三步: 为了实现把在lua文件中,把JSON类型的数据转为lua的table类型,我们需要用到OpenResty提供的cjson模块(也就是/usr/local/openresty/lualib 目录下的cjson.so文件),就可以处理JSON的序列化和反序列化。把 /usr/local/openresty/nginx/lua 目录下的item.lua文件修改为如下
vi /usr/local/openresty/nginx/lua/item.lua
-- 导入写好的common.lua文件,也就是导入common.lua函数库
local common = require('common')
local read_http = common.read_http
-- 导入OpenResty提供的cjson模块(也就是cjson.so文件),用于下面拼接商品、库存信息时,将JSON转化为lua的table类型
local cjson = require('cjson')
-- 获取路径参数
local id = ngx.var[1]
-- 查询商品信息
local itemJSON = read_http("/item/" .. id, nil)
-- 查询库存信息
local stockJSON = read_http("/item/stock/" .. id, nil)
-- 调用cjson函数,拼接商品、库存信息,一起返回给用户,也就是把商品、库存信息一起返回到页面
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
item.stock = stock.stock
item.sold = stock.sold
-- 上面的item此时是table类型,我们还要序列化回来,也就是把table类型转为JSON类型
-- 返回结果,这次没有假数据,全部都是通过虚拟机nginx(也就是OpenResty)去请求Windows的idea的Tomcat拿到的
ngx.say(cjson.encode(item))
第四步: 在任意目录执行如下,表示重新加载Nginx(也就是OpenResty)的配置文件
nginx
nginx -s reload
第五步: 由于现在是虚拟机的Nginx(也就是OpenResty)向Windows的idea的Tomcat查询数据,所以我们需要先启动一些必要的环境,如下
(1) 启动docker里面的mysql容器
systemctl start docker # 启动docker服务
docker restart mysql #启动mysql容器
(2) 启动Windows的Nginx。win+r,输入cmd回车,然后输入如下
d:
cd nginx-1.18.0
start nginx.exe
(3) 启动idea的item-service项目的ItemApplication引导类,浏览器访问如下
http://localhost/item.html?id=10003
五、Tomcat集群的负载均衡
具体操作如下
第一步: 在nginx.conf的http里面定义upstream作为要访问的地址(也就是Tomcat集群),把Nginx的负载均衡算法设置为基于url地址的负载均衡算法,然后把proxy_pass值,从原来的ip地址修改为集群名称,这样虚拟机的Nginx就会访问idea的Tomcat集群
我把整个代码都放出来了,不要复制,只是在原来的基础上修改了一点点,手打就行
vi /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;
# 加载lua模块
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
# 加载c模块
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
upstream tomcat-cluster {
hash $request_uri; #保证了每个id固定就访问到固定的服务器上
server 192.168.127.1:8081;
server 192.168.127.1:8082;
}
server {
listen 8081;
server_name localhost;
# 给/item请求地址做反向代理,ip地址是Windows的地址
# 作用是用户请求/item的时候,实际请求的是windows的idea的tomcat
location /item {
proxy_pass http://tomcat-cluster;
}
location ~ /api/item/(\d+) {
# 响应类型,这里返回json
default_type application/json;
# 响应数据由 lua/item.lua这个文件来决定
content_by_lua_file lua/item.lua;
}
location / {
root html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
第二步: 在任意目录执行如下,表示重新加载Nginx(也就是OpenResty)的配置文件
nginx
nginx -s reload
第三步: 由于现在是虚拟机的Nginx(也就是OpenResty)向Windows的idea的Tomcat查询数据,所以我们需要先启动一些必要的环境,如下
(1) 启动docker里面的mysql容器
systemctl start docker # 启动docker服务
docker restart mysql #启动mysql容器
(2) 启动Windows的Nginx。win+r,输入cmd回车,然后输入如下
d:
cd nginx-1.18.0
start nginx.exe
(3) 如何启动两个Tomcat服务。打开idea软件,在item-service项目,进行下面操作
(4) 测试。浏览器访问10001~10004,看看哪个Tomcat响应来自虚拟机Nginx的请求,响应的规律是什么
http://localhost/item.html?id=10004
六、Redis缓存预热
冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力
缓存预热:在实际开发中,我们可以利用大数据统计用户访问的热点数据,在项目启动时将这些热点数据提前查询并保存到Redis中。对于我们现在这个项目来说,由于数据量较少,所以可以在启动时将所有数据都放入缓存中
【缓存预热】
利用Docker安装Redis
docker run --name redis -p 6379:6379 -d redis redis-server --appendonly yes
在item-service服务中引入Redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置Redis地址
spring:
redis:
host: 192.168.127.180
编写初始化类
package com.heima.item.config;
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;
/**
* @author 35238
* @date 2023/6/28 0028 22:47
*/
@Component
//实现redis缓存的预热功能
public class RedisHandler implements InitializingBean {
@Autowired
//注入redis提供的StringRedisTemplate类,用于操作redis
private StringRedisTemplate redisTemplate;
@Autowired
//注入写好的IItemService接口,用于查询商品信息
private IItemService itemService;
@Autowired
//注入写好的IItemStockService接口,用于查询库存信息
private IItemStockService stockService;
@Autowired
//注入spring提供的JSON处理工具,用于序列化为JSON。为方便使用,我们写成静态常量
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
//初始化缓存
public void afterPropertiesSet() throws Exception { //在完成初始化后执行 成员变量都赋值后
//查询商品信息
List<Item> itemList = itemService.list();
//把查到的商品信息放入缓存
for (Item item : itemList) {
//把item(拿到的item是lua语言的table类型)序列化为JSON
String json = MAPPER.writeValueAsString(item);//把任意对象转为json字符串
//把序列化后的item存入redis
//由于商品信息的key存入redis后,可能与后面的库存信息的key重复,
//所以在存key的时候,我们要给key加上前缀,其实就是拼接一个字符串,冒号用于分隔便于观察
redisTemplate.opsForValue().set("item:id:" +item.getId(),json);
}
//查询库存信息
List<ItemStock> stockList = stockService.list();
//把查到的库存信息放入缓存
for (ItemStock stock : stockList) {
//把item(拿到的item是lua语言的table类型)序列化为JSON
String json = MAPPER.writeValueAsString(stock);//把任意对象转为json字符串
//把序列化后的item存入redis
//由于库存信息的key存入redis后,可能与前面的商品信息的key重复,
//所以在存key的时候,我们要给key加上前缀,其实就是拼接一个字符串,冒号用于分隔便于观察
redisTemplate.opsForValue().set("stock:id:" +stock.getId(),json);
}
}
}
七、查询Redis缓存
刚刚我们已经实现了redis(虚拟机的Redis)的缓存预热功能,提前把数据存入了Redis当中,下面我们就需要需要修改OpenResty(就是虚拟机的Nginx)的逻辑,让OpenResty优先查询Redis,Redis未命中再查询Tomcat(idea的Tomcat),我们需要先解决如何在OpenResty来操作Redis,解决: 使用OpenResty提供的操作Redis的模块
首先: 引入Redis模块,并初始化Redis对象
-- 引入redis模块
local redis = require("resty.redis")
-- 初始化Redis对象
local red = redis:new()
-- 设置Redis超时时间(建立连接的超时时间,发送请求的超时时间,响应结果的超时时间)
red:set_timeouts(1000, 1000, 1000)
然后: 封装函数,用来释放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
然后: 封装函数,作用是从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
案例: 查询商品时,优先Redis缓存查询,需求如下
1、修改item.lua,封装一个函数read_data,实现先查询Redis,如果未命中,再查询tomcat
2、修改item.lua,查询商品和库存时都调用read_data这个函数
-- 封装函数,先查询redis,再查询http
local function read_data(key, path, params)
-- 查询redis
local resp = read_redis("127.0.0.1", 6379, key)
-- 判断redis是否命中
if not resp then
-- Redis查询失败,查询http
resp = read_http(path, params)
end
return resp
end
具体操作如下
第一步: 把/usr/local/openresty/lualib目录的common.lua文件,修改为如下
-- 导入OpenResty提供的Redis模块
local redis = require('resty.redis')
-- 初始化导入的Redis,也就是创建redis对象
local red = redis:new()
-- 为redis设置超时时间,括号内的参数分别表示'建立连接的超时时间'、'发送请求的超时时间'、'响应结果的超时时间'
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的连接,并且读数据
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
第二步: 把/usr/local/openresty/nginx/lua目录的item.lua文件,修改为如下
-- 导入写好的common.lua文件的common.lua函数、read_redis函数
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 导入OpenResty提供的cjson模块(也就是cjson.so文件),用于下面拼接商品、库存信息时,将JSON转化为lua的table类型
local cjson = require('cjson')
-- 封装函数
function read_data(key, path, params)
-- 先查询Redis,也就是先查询虚拟机的Redis
local resp = read_redis("192.168.127.180", 6379, key)
-- 判断查询结果,如果未命中Redis,再查询数据库
if not resp then
-- 记录日志
ngx.log("redis查询未命中,将要去查询数据库。查不到的key是: ",key)
-- redis查询未命中 就查询数据库
resp = read_http(path, params)
end
return resp
end
-- 获取路径参数
local id = ngx.var[1]
-- 查询商品信息,调用上面封装好的read_data函数。注意下面括号的第一个参数是Redis的key(用了前缀拼接),要跟你的idea的RedisHandler类里面的key一致(当时也用了前缀拼接)
local itemJSON = read_data("item:id:" .. id, "/item/" .. id, nil)
-- 查询库存信息
local stockJSON = read_data("stock:id:" .. id, "/item/stock/" .. id, nil)
-- 调用cjson函数,拼接商品、库存信息,一起返回给用户,也就是把商品、库存信息一起返回到页面
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
item.stock = stock.stock
item.sold = stock.sold
-- 上面的item此时是table类型,我们还要序列化回来,也就是把table类型转为JSON类型
-- 返回结果,这次没有假数据,全部都是通过虚拟机nginx(也就是OpenResty)去请求Windows的idea的Tomcat拿到的
ngx.say(cjson.encode(item))
第三步: 启动nginx,就是启动OpenResty。不执行这一步的话,下面的第四步执行不了
nginx
第四步: 在任意目录执行如下,表示重新加载Nginx(也就是OpenResty)的配置文件
nginx -s reload
第五步: 需要先启动一些必要的环境,如下
(1) 启动docker里面的mysql容器、redis容器
systemctl start docker # 启动docker服务
docker restart mysql #启动mysql容器
docker start redis #启动redis容器
(2) 启动Windows的Nginx。win+r,输入cmd回车,然后输入如下
d:
cd nginx-1.18.0
start nginx.exe
第六步: 在idea的item-service项目,运行ItemApplication引导类、ItemApplication2引导类。让Nginx(也就是虚拟机的OpenResty)先去idea的Tomcat读取数据存入Redis(虚拟机的redis容器),然后停止运行ItemApplication引导类、ItemApplication2引导类,去浏览器访问如下地址,测试redis是否生效,也就是让Nginx去查询Redis,而不是查询Tomcat
http://localhost/item.html?id=10003
八、Nginx本地缓存
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')
案例: 在查询商品时,优先查询OpenResty的本地缓存。需求如下
1、修改item.lua中的read_data函数,优先查询本地缓存,未命中时再查询Redis、Tomcat
2、查询Redis或Tomcat成功后,将数据写入本地缓存,并设置有效期
3、商品基本信息,有效期30分钟
4、库存信息,有效期1分钟
具体操作如下
第一步: 把/usr/local/openresty/nginx/conf目录的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;
# 加载lua模块
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
# 加载c模块
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
# 添加共享词典功能,也就是本地缓存功能。item_cache是自定义缓存名称,150m是缓存最多存到150MB
lua_shared_dict item_cache 150m;
upstream tomcat-cluster {
hash $request_uri;
server 192.168.127.1:8081;
server 192.168.127.1:8082;
}
server {
listen 8081;
server_name localhost;
# 给/item请求地址做反向代理,ip地址是Windows的地址
# 作用是用户请求/item的时候,实际请求的是windows的idea的tomcat
location /item {
proxy_pass http://tomcat-cluster;
}
location ~ /api/item/(\d+) {
# 响应类型,这里返回json
default_type application/json;
# 响应数据由 lua/item.lua这个文件来决定
content_by_lua_file lua/item.lua;
}
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/lua目录的item.lua文件,修改为如下
-- 导入写好的common.lua文件的common.lua函数、read_redis函数
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 导入OpenResty提供的cjson模块(也就是cjson.so文件),用于下面拼接商品、库存信息时,将JSON转化为lua的table类型
local cjson = require('cjson')
-- 导入OpenResty提供的共享词典,也就是导入Nginx本地缓存的模块
local item_cache = ngx.shared.item_cache
-- 封装函数。注意expire是下面需要把数据存入Nginx时,的缓存有效期,单位是秒
function read_data(key, expire, path, params)
-- 不优先查Redis,而是优先查询Nginx(也就是OpenResty)本地缓存
local val = item_cache:get(key)
-- 判断得到的val是否为空,也就是Nginx本地缓存里面是否有用户要查询的数据
if not val then
ngx.log(ngx.ERR, "Nginx本地缓存查询未命中,将要去查询redis。查不到的key是: ",key)
-- 然后才查询Redis,也就是先查询虚拟机的Redis
val = read_redis("192.168.127.180", 6379, key)
-- 判断查询结果,如果未命中Redis,再查询数据库
if not val then
-- 记录日志
ngx.log(ngx.ERR, "redis查询未命中,将要去查询数据库。查不到的key是: ",key)
-- redis查询未命中
val = read_http(path, params)
end
end
-- Nginx本地缓存->Redis->mysql,经过这一轮必然会查询成功,查询成功后,不着急返回,先把数据写入Nginx本地缓存,方便下次直接走Nginx本地缓存
item_cache:set(key, val, expire)
-- 返回数据
return val
end
-- 获取路径参数
local id = ngx.var[1]
-- 查询商品信息,调用上面封装好的read_data函数。注意下面括号的第一个参数是Redis的key(用了前缀拼接),要跟你的idea的RedisHandler类里面的key一致(当时也用了前缀拼接)
local itemJSON = read_data("item:id:" .. id, 1800, "/item/" .. id, nil)
-- 查询库存信息。注意第二个参数是缓存在存入本地Nginx时的缓存有效期,单位是秒
local stockJSON = read_data("stock:id:" .. id, 60, "/item/stock/" .. id, nil)
-- 调用cjson函数,拼接商品、库存信息,一起返回给用户,也就是把商品、库存信息一起返回到页面
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
item.stock = stock.stock
item.sold = stock.sold
-- 上面的item此时是table类型,我们还要序列化回来,也就是把table类型转为JSON类型
-- 返回结果,这次没有假数据,全部都是通过虚拟机nginx(也就是OpenResty)去请求Windows的idea的Tomcat拿到的
ngx.say(cjson.encode(item))
第三步: 启动nginx,就是启动OpenResty。不执行这一步的话,下面的第四步执行不了
nginx
第四步: 在任意目录执行如下,表示重新加载Nginx(也就是OpenResty)的配置文件
nginx -s reload
第五步: 需要先启动一些必要的环境,如下,
(1) 启动docker里面的mysql容器、redis容器
systemctl start docker # 启动docker服务
docker restart mysql #启动mysql容器
docker start redis #启动redis容器
(2) 启动Windows的Nginx。win+r,输入cmd回车,然后输入如下
d:
cd nginx-1.18.0
start nginx.exe
(3) 在idea的item-service项目,运行ItemApplication引导类、ItemApplication2引导类。注意必须运行着,如果关了的话,第一次查询必然请求报错,第二次开始就不报错了,建议开着,这样第一次查询就不会出错
第六步: 测试。先打开Nginx的日志,使用tail命令进行实时监控日志信息的输出
cd /usr/local/openresty/nginx/logs
tail -f error.log
第七步: 测试。浏览器第一次访问如下,这是第一次查询,失败很正常,因为Nginx本地缓存还没有这条数据。下图纠正一下,我们设置的过期时间是60秒
http://localhost/item.html?id=10003
第八步: 测试。浏览器第二次访问如下,由于在第一次查询后,Nginx本地缓存就会有了这条数据。所以第二次查询走的是Nginx本地缓存
http://localhost/item.html?id=10003
总结: 在 ‘5. 多级缓存的最终实现’ 里面,我们实现了一条如下的请求链
用户 -> Windows的Nginx -> 虚拟机的Nginx(也就是OpenResty) -> 虚拟机的Nginx(也就是OpenResty)的本地缓存 -> 虚拟机的Redis容器 ->
idea的Tomcat -> idea的Tomcat的进程缓存 -> 数据库
在实现多级缓存后,还有一个很重要的问题,就是如何实现这么多级缓存之间的数据同步,下面将深入学习缓存同步
缓存同步
一、数据同步策略
缓存数据同步的常见方式有三种:
1、设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新
- 优势:简单、方便
- 缺点:时效性差,缓存过期之前可能不一致
- 场景:更新频率较低,时效性要求低的业务
2、同步双写:在修改数据库的同时,直接修改缓存
- 优势:时效性强,缓存与数据库强一致
- 缺点:有代码侵入,耦合度高
- 场景:对一致性、时效性要求较高的缓存数据
3、异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据
- 优势:低耦合,可以同时通知多个缓存服务
- 缺点:时效性一般,可能存在中间不一致状态
- 场景:时效性要求一般,有多个服务需要同步
【具体实现就下面两种,我们采取的是第二种,也就是基于Canal的异步通知】
基于MQ的异步通知(这个可以实现,但我们下面不演示这种,因为这种是要对项目代码进行修改)
基于Canal的异步通知(我们下面演示这个,优点是使用Canal监听数据库的变化,不需要修改项目代码,代码零侵入、零耦合,效率最高):
二、安装Canal
Canal 读 kē nǒu,译意为水道/管道/沟渠,canal是阿里巴巴旗下的一款开源项目,基于Java开发。基于数据库增量日志解析,提供增量数据订阅&消费
GitHub的地址:https://github.com/alibaba/canal
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是基于MySQL的主从同步功能,因此必须先开启MySQL的主从功能才可以。我们在前面的 ‘2. JVM进程缓存案例搭建’ 里面搭建了一个mysql容器,以这个Docker运行的mysql容器为例。下面我们会在docker里面,使用Canal镜像创建Canal容器。安装Canal的具体操作如下:
第一步: 启动docker里面的mysql容器,并且进入mysql容器终端,登录mysql,查一下你业务使用的是哪个数据库
systemctl start docker # 启动docker服务
docker restart mysql #启动mysql容器
docker exec -it mysql bash #进入mysql容器的终端
mysql -u root -p123 #登录mysql
show databases #查看有哪些database
第二步: 在mysql容器挂载的日志文件,添加如下
vi /tmp/mysql/conf/my.cnf
log-bin=/var/lib/mysql/mysql-bin
binlog-do-db=JvmDataShop
# log-bin=/var/lib/mysql/mysql-bin:设置binary log文件的存放地址和文件名,叫做mysql-bin
# binlog-do-db=JvmDataShop:指定对哪个database记录binary log events,这里记录JvmDataShop这个库
第三步: 再次进入mysql容器终端,登录mysql,创建一个用于数据同步的用户,用户名和密码都为canal,为canal用户授予所有权限
docker exec -it mysql bash #进入mysql容器的终端
mysql -u root -p123 #登录mysql
create user 'canal'@'%' IDENTIFIED by 'canal'; # 创建用户
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%' identified by 'canal'; #授权
FLUSH PRIVILEGES; # 刷新权限
第四步: 重启mysql容器,然后查看/tmp/mysql/data目录,里面有没有mysql-bin.000001文件
exit # 退出mysql的视图
exit # 退出mysql容器终端的视图
docker restart mysql
ll /tmp/mysql/data
第五步: 现在开始安装Canal。先创建一个网络,将mysql容器放到这个Docker网络中
docker network create heima
docker network connect heima mysql
第六步: 下载链接提供的canal的镜像压缩包到Windows,然后上传到虚拟机的 /root 目录
canal的镜像压缩包下载: https://cowtransfer.com/s/ff2ee7d6bf2644
第七步: 把 /root 目录下的canal镜像压缩包导入docker,然后查看docker里面是否有canal镜像
cd /root
docker load -i canal.tar
docker images
第八步: 在docker,使用canal镜像,来创建并运行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=JvmDataShop\\..* \
--network heima \
-d canal/canal-server:v1.1.5
# --name canal: 自定义容器名称,例如叫canal
# -p 11111:11111: 这是canal的默认监听端口
# -e canal.destinations: 自定义canal的实例名称
# -e canal.instance.master.address=mysql:3306:数据库地址和端口,由于canal和mysql会在同一网络,所以可以使用容器名进行访问,用来代替ip地址
# -e canal.instance.dbUsername=canal: 数据库用户名 主数据库提供的
# -e canal.instance.dbPassword=canal: 数据库密码
# -e canal.instance.filter.regex=: canal监听的是mysql的哪个库哪个表,监听的写法是有讲究的,常见语法如下
表名称监听支持的语法:
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的日志
docker logs -f canal
第十步: 验证canal容器是否跟mysql容器建立了连接。我们需要进入canal容器的终端,再通过tail命令,查看的是canal的日志、查看heima网络的日志
docker exec -it canal bash
tail -f canal-server/logs/canal/canal.log
tail -f canal-server/logs/heima/heima.log
纠正上图: canal不会去读取mysql的数据,只是监听mysql的数据有没有变化。heima并不是网络,而是上面第八步设置的canal的实例名称
三、Canal实现缓存同步
下面的操作是紧接着上面的 ‘二、安装Canal’,请先完成 ‘二、安装Canal’,再进行下面的操作
我们刚刚使用canal(虚拟机的canal容器)监听到了mysql(虚拟机的mysql容器)的数据日志,当canal监听到mysql的数据发生了变化,怎么才能告知redis(虚拟机的redis容器)或Tomcat(idea的item-service项目)呢 ? 下面就来学习
Canal提供了各种语言(包括java语言)的客户端,当Canal监听到binlog变化时,会通知Canal的客户端
我们只需要在idea的Tomcat里面编写这个客户端,让这个客户端在接收到canal的通知之后,去更新Redis,让Redis的缓存保持最新数据
我们使用GitHub上的第三方开源的canal-starter。地址: https://github.com/NormanGyllenhaal/canal-client
具体操作如下
第一步: 在item-service项目的pom.xml文件,添加如下
<!--引入canal依赖-->
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
第二步: 在item-service项目的application.yml文件,添加如下
# 配置Canal的地址、实例名称
canal:
destination: heima
server: 192.168.127.180:11111
第三步: Canal推送给canal-client的是被修改的这一行数据(row),而我们引入的canal-client则会帮我们把行数据封装到Item实体类中。这个过程中需要知道数据库与实体的映射关系,要用到JPA的几个注解。把Item类修改为如下
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") //表中与属性名不一致的字段。我们目前是一样的,这里写的是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//不属于表中的字段,将来canal会忽略这个字段
private Integer stock;
@TableField(exist = false)
@Transient//不属于表中的字段,将来canal会忽略这个字段
private Integer sold;
}
第四步: 在RedisHandler类,添加如下,用于操作redis
//往Redis写入数据
public void saveItem(Item item){
try {
//把item(拿到的item是lua语言的table类型)序列化为JSON
String json = MAPPER.writeValueAsString(item);//把任意对象转为json字符串
//把序列化后的item存入redis
//由于商品信息的key存入redis后,可能与后面的库存信息key重复,所以存key的时候,我们给key加上前缀,也就是拼接一个字符串,冒号用于分隔便于观察
redisTemplate.opsForValue().set("item:id:" +item.getId(),json);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
//删除在redis的数据
public void deleteItemById(Long id){
redisTemplate.delete("item:id:" + id);
}
第五步: 编写监听器,监听Canal消息。在item-service项目的com.heima.item目录新建canal.ItemHandler类,写入如下
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;
/**
* @author 35238
* @date 2023/6/30 0030 16:29
*/
@CanalTable("tb_item")//指定要监听的表
@Component
//下面的Item是tb_item表对应的实体类
public class ItemHandler implements EntryHandler<Item> {
@Autowired
//注入caffeine提供的Cache接口,用来操作Tomcat进程缓存(也就是JVM进程缓存)
private Cache<Long, Item> itemCache;
@Autowired
//注入刚刚添加了操作redis方法的RedisHandler类
private RedisHandler redisHandler;
@Override
//insert方法是canal提供的EntryHandler接口里面的
//canal监听数据库,如果发生新增数据,要做什么事情
public void insert(Item item) {
//写数据到Tomcat进程缓存(也就是JVM进程缓存)。put方法的第一个参数是key,第二个参数是Value
itemCache.put(item.getId(), item);
//写数据到redis缓存
redisHandler.saveItem(item);
}
@Override
//insert方法是canal提供的EntryHandler接口里面的
//canal监听数据库,如果发生修改数据,要做什么事情。下面的before是更新前的数据,after是更新后的数据
public void update(Item before, Item after) {
//写数据到Tomcat进程缓存(也就是JVM进程缓存)。put方法的第一个参数是key,第二个参数是Value
itemCache.put(after.getId(), after);
//修改数据到redis缓存
redisHandler.saveItem(after);
}
@Override
//insert方法是canal提供的EntryHandler接口里面的
//canal监听数据库,如果发生删除数据,要做什么事情
public void delete(Item item) {
//删除数据到Tomcat进程缓存(也就是JVM进程缓存),根据id进行删除
itemCache.invalidate(item.getId());
//删除数据到redis缓存
redisHandler.deleteItemById(item.getId());
}
}
第六步: 需要先启动一些必要的环境,如下,
(1) 启动docker里面的mysql容器、redis容器
systemctl start docker # 启动docker服务
docker restart mysql #启动mysql容器
docker start redis #启动redis容器
docker start canal #启动canal容器
nginx # 启动nginx,就是启动OpenResty
(2) 启动Windows的Nginx。win+r,输入cmd回车,然后输入如下
d:
cd nginx-1.18.0
start nginx.exe
第七步: 运行idea的item-service项目,运行ItemApplication引导类、ItemApplication2引导类。查看idea控制台信息
第八步: 测试。当我们对数据库(虚拟机的mysql容器)的数据进行修改时,是否能被canal监控到,从而告知Redis、Tomcat进程缓存去及时更新最新数据。注意由于我们没有去让canal操作Nginx(虚拟机的OpenResty)本地缓存,只是让canal去操作Redis、Tomcat,所以为了直观看到测试结果,直接访问本地,我们可以根据如下步骤进行
(1) 浏览器访问如下,拿到id为10001的数据
http://localhost:8081/item/10001
(2) 浏览器访问如下,我们去修改id为10001的数据,就能够触发canal
http://localhost:8081
(3) 查看idea控制台
(4) 浏览器访问如下,看一下数据有没有更新过来
http://localhost:8081/item/10001
(5) 那用户拿到的数据,到底是redis还是tomcat返回的呢 ? 首先,缓存刚更新的时候,用户拿的是tomcat的进程缓存,因为tomcat的处理速度比redis快,等redis也把自己的本地缓存更新之后,就会一直拿redis的本地缓存,如果Nginx也有,就优先拿Nginx的,也就是下面那条请求链
用户页面->通过ajax发送请求 -> Windows的Nginx -> 虚拟机的Nginx(也就是OpenResty) -> 虚拟机的Nginx(也就是OpenResty)的本地缓存 -> 虚拟机的Redis容器 ->
idea的Tomcat -> idea的Tomcat的进程缓存 -> 数据库
四、总结
注意在上面的请求链中,我们除了没有对Nginx的缓存进行同步(但是有缓存失效,我们当时设置的是60秒,所以其实对Nginx也是进行了缓存同步,只不过是被动的)之外,所以Nginx缓存一些实时性不高的数据,对redis和tomcat都进行了缓存同步,并且是使用canal做的缓存同步,数据永远都是最新的,redis和tomcat缓存一些实时性高的数据
整体来说,这个请求链是实现了多级缓存+缓存同步,也就是下图的全部功能