多级缓存实现 - nginx本地缓存 - redis缓存 - JVM缓存
概要
步骤:
1. 安装redis 省略
2. 安装OpenResty
2.1 OpenResty概要说明
OpenResty是一个基于Nginx的高性能Web平台,用于方便地搭建能够处理超高并发、扩展性极高的动态Web应用、Web服务和动态网关,具有以下特点:
-
具备Nginx的完整功能
-
基于Lua语言进行扩展,集成了大量的Lua库、第三方模块
-
允许使用Lua自定义业务逻辑、自定义库
官方网站 -- https://openresty.org/cn
官网安装说明 -- https://openresty.org/cn/installation.html
2.2 安装
2.2.1 安装前准备,需要先安装这些库 perl 5.6.1+ ,libpcre, libssl 执行下面命令安装Debian和ubuntu用户
apt-get install libpcre3-dev libssl-dev perl make build-essential curl
2.2.2 下载OpenResty源码
https://openresty.org/cn/download.html
滚动到页面最下方可下载的历史版本
2.2.3 解压
tar -xzvf openresty-VERSION.tar.gz
2.2.4 然后在进入 openresty-VERSION/ 目录
输入命令 ./config
默认安装到/usr/local/openresty目录,可以用下面命令指定安装目录
./config --prefix=指定目录
2.2.5 编译
输入命令 make
2.2.6 安装
输入命令 make install
安装完成后的目录结构
进入到bin目录,openresty的启动其实就是将所集成的nginx服务启动
启动: 安装目录/nginx/sbin/nginx
停止: 安装目录/nginx/sbin/nginx -s stop
重启: 安装目录/nginx/sbin/nginx -s reload
访问: 打开浏览器输nginx.conf中配置的端口号
3. 安装Canal
3.1 Canal概要说明
Canal是阿里巴巴旗下的一款开源项目,基于java开发,基于数据库增量日志解析,提供增量数据订阅&消费, GitHub地址:https://github.com/alibaba/canal
Canal是基于Mysql的主从同步来实现的,MySQL主从同步的原理如下:
-
MySQL master将数据变更写入二进制日志(binary log),其中记录的数据叫做binary log events
-
MySQL slave将master的binary log events拷贝到它的中继日志(relay log)
-
MySQL slave重放relay log中事件,将数据变更反映它自己的数据
Canal就是把自己伪装成MySQL的一个slave节点,从而监听master的binary log变化,再把得到的变化信息通知给Canal的客户端,进而完成对器数据库的同步。
3.2 安装
3.2.1 下载Canal
下载地址: https://github.com/alibaba/canal/releases
3.2.2 解压到指定目录
tar -zxvf canal.deployer-version.tar.gz -C /usr/local/canal
-C后面接安装目录
3.2.3 解压后的目录结构
3.2.4 修改conf/canal.properties文件中以下属性,如没有则添加
# canal监听端口,默认也是11111
canal.port = 11111
# canal实例名称
canal.destinations = example
# mysql数据库地址和端口
canal.instance.master.address=localhost:3306
# mysql数据库用户名
canal.instance.dbUsername=canal
# mysql数据库密码
canal.instance.dbPassword=canal
# 编码方式
canal.instance.connectionCharset=UTF-8
canal.instance.tsdb.enable=true
canal.instance.gtidon=false
# 要监听的表名称
# 表名支持正则表达式,多个正则之间逗号(,)分隔,转义字符要用双斜杠(\\)
# 所有表: .*
# canal库下所有表: canal\\..*
# canal下以canal打头的表: canal\\.canal.*
# canal下的test表: canal.test
canal.instance.filter.regex=goods\\..*
3.2.5 启动Canal
./bin/startup.sh
关闭Canal
./bin/stop.sh
3.2.6 查看Canal日志
查看canal日志
tail -f ./logs/canal/canal.log
查看实例日志
tail -f ./logs/example/example.log
以下内容说明启动成功
3.2.7 在mysql中创建canal用户并分配权限
create user canal@'%' IDENTIFIED by 'canal';
GRANT SELECT,REPLICATION SLAVE,REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%' identified by 'canal';
FLUSH privileges;
3.2.8 配置mysql的主从同步并重启mysql
修改my.cnf文件添加以下内容:
# 设置binary log文件存储地址和文件名
log_bin=/var/log/mysql/mysql-bin
# 指定对哪个database记录binary log events,这里记录goods这个库
binlog_do_db=goods
注:如启动失败请先启动Canal之后再启动mysql
重启mysql: service mysql restart
mysql启动后在/var/log/mysql/生成以下文件,.000001会不断的自增
4. 编写服务器端JVM缓存代码和redis存储代码
4.1 处理流程
-
工程启动将商品信息添加到jvm缓存,同时存储到redis
-
定义查询接口api,根据id查询商品,优先到jvm缓存获取,未查询到再到mysql数据库获取,mysql中查询到再将其添加到jvm缓存
-
canal监听product表的变化,实现jvm缓存和redis的同步
4.2 工程目录结构
4.3 依赖-springboot版本-jdk版本
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mysql 连接驱动依赖 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.20</version>
</dependency>
<!-- mybatis-plus依赖 不再需要额外引入mybatis依赖 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<!-- mybatis-plus代码生成器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.1</version>
</dependency>
<!-- mybatis-plus代码生成器依赖的Velocity引擎模板 -->
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.1</version>
</dependency>
<!-- mybatis-plus代码生成器依赖的Freemarker引擎模板 -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.28</version>
</dependency>
<!-- redis依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- redis的lettuce依赖commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>4.5.16</version>
</dependency>
<!-- caffeine缓存依赖 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.8.0</version>
</dependency>
<!-- Canal -->
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
</dependencies>
4.4 application.yml配置
spring:
datasource:
url: jdbc:mysql://192.168.128.128:3306/goods?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 123456
redis:
host: localhost
database: 1
port: 6379
password: 123456
timeout: 300ms
canal:
destination: example # canal实例名称
server: 192.168.128.128:11111 # canal地址
4.5 MybatisPlus自动生成器代码
package com.qiang.cache.generator;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.DataSourceConfig;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.config.converts.MySqlTypeConvert;
import com.baomidou.mybatisplus.generator.config.querys.MySqlQuery;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import com.baomidou.mybatisplus.generator.keywords.MySqlKeyWordsHandler;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 代码生成器
*/
public class MybatisGenerator {
public static void main(String[] args) {
//获得当前项目路径
String projectPath = System.getProperty("user.dir") + "\\";
String parent = "com.qiang.cache";
List<String> tables = new ArrayList<>();
tables.add("product");//数据库-表名
System.out.println("projectPath:"+projectPath);
DataSourceConfig.Builder dataSourceConfig = new DataSourceConfig
.Builder(
"jdbc:mysql://192.168.128.128:3306/goods?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC",
"root",
"123456")
.dbQuery(new MySqlQuery())
.typeConvert(new MySqlTypeConvert())
.keyWordsHandler(new MySqlKeyWordsHandler());
FastAutoGenerator.create(dataSourceConfig)
.globalConfig(builder -> {
builder.author("yuqiang") // 设置作者
.fileOverride() // 覆盖已生成文件
.outputDir(projectPath + "src/main/java")
.disableOpenDir(); // 指定输出目录
})
.packageConfig(builder -> {
builder.parent(parent) // 设置父包名
// .moduleName("test") // 设置父包模块名
.entity("dto")
.service("service")
.serviceImpl("service.impl")
.mapper("mapper")
.xml("mapper.xml")
.controller("controller")
.pathInfo(Collections.singletonMap(OutputFile.mapperXml, projectPath + "src/main/resources/mapper")); // 设置mapperXml生成路径
})
.strategyConfig(builder -> {
builder.addInclude(tables)// 设置需要生成的表名
// .addTablePrefix("t_", "c_"); // 设置过滤表前缀
.entityBuilder()
.formatFileName("%s")
.controllerBuilder()
.enableRestStyle()
.formatFileName("%sController").build()
.serviceBuilder()
.formatServiceFileName("%sService")
.formatServiceImplFileName("%sServiceImpl").build()
.mapperBuilder()
.enableMapperAnnotation()
.formatMapperFileName("%sMapper")
.formatXmlFileName("%sMapper");
})
.templateEngine(new FreemarkerTemplateEngine()) // 使用Freemarker引擎模板,默认的是Velocity引擎模板
.execute();
}
}
4.6 配置JVM缓存Bean
package com.qiang.cache.config;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.qiang.cache.dto.Product;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CaffeineConfig {
@Bean
public Cache<Integer, Product> productCache(){
return Caffeine.newBuilder()
.initialCapacity(100)//初始容量
//最大容量,达到内存上限后会对内存做清理,访问量少的会先被清除
.maximumSize(10000)
//设置缓存有效期
//默认情况下,当一个缓存元素过期时,Caffeine不会自动立即将其清除和驱逐,
//而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。
// .expireAfterWrite(Duration.ofSeconds(30*60))
.build();
}
}
4.7 工程启动自动将商品信息添加到redis和jvm缓存
package com.qiang.cache.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.qiang.cache.dto.Product;
import com.qiang.cache.service.ProductService;
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;
import java.util.concurrent.TimeUnit;
/**
* 实现InitializingBean接口的类会在该类实例化完成后执行afterPropertiesSet函数
* 所有会在项目启动之后会自动执行afterPropertiesSet函数
*/
@Component
public class RedisHandler implements InitializingBean {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductService productService;
@Autowired
private Cache<Integer,Product> productCache;
private static final ObjectMapper mapper=new ObjectMapper();
@Override
public void afterPropertiesSet() throws Exception {
//项目启动时将全部商品信息存储到redis,正常业务是不会这样做的,需要通过计算或大数据运算来决定将哪些商品添加到redis
//查询所有商品
List<Product> list = productService.list();
if(list!=null && !list.isEmpty()){
for(Product item:list){
//将product添加到jvm缓存
productCache.put(item.getId(),item);
//将Product对象转换成json字符串
String strJson=mapper.writeValueAsString(item);
//设置30分钟后过期
redisTemplate.opsForValue().set("prod:id:"+item.getId(),strJson,30*60*1000, TimeUnit.SECONDS);
}
}
}
}
4.8 创建Canal监听product表的更新
package com.qiang.cache.config;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.qiang.cache.dto.Product;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import top.javatool.canal.client.annotation.CanalTable;
import top.javatool.canal.client.handler.EntryHandler;
import java.util.concurrent.TimeUnit;
/**
* CanalTable指定要监听的表
* EntryHandler<Product> 指定关联的表的实体类
*/
@CanalTable("product")
@Component
public class CacheHandler implements EntryHandler<Product> {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private Cache<Integer,Product> productCache;
private static final ObjectMapper mapper=new ObjectMapper();
@Override
public void insert(Product product) {
//新增Product表数据时触发
try {
//数据添加到redis
//将Product对象转换成json字符串
String strJson = mapper.writeValueAsString(product);
//设置30分钟后过期
redisTemplate.opsForValue().set("prod:id:"+product.getId(),strJson,30*60*1000, TimeUnit.SECONDS);
//将product添加到jvm缓存
productCache.put(product.getId(),product);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
@Override
public void update(Product before, Product after) {
//修改Product表数据时触发
try {
//数据添加到redis
//将Product对象转换成json字符串
String strJson = mapper.writeValueAsString(after);
//设置30分钟后过期
redisTemplate.opsForValue().set("prod:id:"+after.getId(),strJson,30*60*1000, TimeUnit.SECONDS);
//将product添加到jvm缓存
productCache.put(after.getId(),after);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void delete(Product product) {
//删除Product表数据时触发
//删除jvm缓存
productCache.invalidate(product.getId());
//删除redis数据
redisTemplate.delete("prod:id:"+product.getId());
}
}
4.9 创建api接口实现根据id查询商品
package com.qiang.cache.controller;
import com.github.benmanes.caffeine.cache.Cache;
import com.qiang.cache.dto.Product;
import com.qiang.cache.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/product")
public class ProductController {
@Autowired
private Cache<Integer,Product> productCache;
@Autowired
private ProductService productService;
@GetMapping("/{id}")
public Product findById(@PathVariable("id") Integer id){
//先到jvm缓存获取product
Product product = productCache.getIfPresent(id);
if(product!=null){
//获取到直接返回
return product;
}
//jvm缓存中未查询到,则到mysql数据库中查询
product =productService.query().eq("id",id.intValue()).one();
if(product!=null){
// 查询到将其添加到jvm缓存
productCache.put(id,product);
}
return product;
// //productCache.get函数会先到cache中根据id查找,如果没有
// //会执行后面的函数到数据库中查询
// return productCache.get(id,key->productService.query()
// .eq("id",key).one()
// );
}
}
4.10 html部署在nginx中需要配置前后端分离
增加下面配置类即可
@Configuration
public class GlobalCorsConfig {
/**
* 允许跨域调用的过滤器
*/
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
//允许所有域名进行跨域调用
config.addAllowedOrigin("*");
//允许跨越发送cookie
config.setAllowCredentials(true);
//放行全部原始头信息
config.addAllowedHeader("*");
//允许所有请求方法跨域调用
config.addAllowedMethod("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
5. 修改/local/openresty/nginx/conf/nginx.conf配置,看下面增加的部分
说明: 配置nginx执行lua脚本, lua脚本内完成本地缓存、nginx直接访问redis,本地缓存和redis不存在再访问http
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# 增加 - lua 模块
lua_package_path "/home/qiang/local/openresty/lualib/?.lua;;";
# 增加 - c 模块
lua_package_cpath "/home/qiang/local/openresty/lualib/?.so;;";
# 增加 - 添加共享词典,本地缓存,名称叫做:item_cache, 大小150m
lua_shared_dict item_cache 150m;
# 增加 - location /product会反向代理到下面的server集群
upstream tomcat-cluster {
# 不添加hash则表示集群会按照顺序轮询请求server地址
# 添加hash后可以保证同一个请求地址会被指向同一个server地址
hash $request_uri;
server 192.168.1.100:8080;
server 192.168.1.100:8082;
server 192.168.1.100:8083;
}
server {
listen 8081;
server_name localhost;
# 增加 - 在lua/product.lua脚本内使用了/product作为请求服务器的地址
# 使用下面代理后请求地址会重定向到http://tomcat-cluster/product/*
# tomcat-cluster会被上面定义的upstream tomcat-cluster中的server取代
location /product {
proxy_pass http://tomcat-cluster;
}
# 增加 - ~表示后面可以接正则表达式
# (\d+)表示后面至少有一个数字
# 如可以匹配html页面内请求服务器的url为/api/item/1的地址
location ~ /api/item/(\d+) {
# 默认响应类型
default_type application/json;
# 响应结果由lua/product.lua文件决定
content_by_lua_file lua/product.lua;
}
location / {
root html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
6. 编写lua脚本
6.1 封装http请求和访问redis的lua脚本
在/local/openresty/lualib目录下创建common.lua文件,内容如下
在lualib目录中的.lua文件会自动被加载,所以可以在该目录创建自定义函数方便后期使用
common.lua文件内容如下:
-- 引入redis模块
local redis = require('resty.redis')
-- 初始化Redis对象
local red = redis:new()
-- 设置Redis超时时间,分别是建立连接、发起请求和请求响应的超时时间
red:set_timeouts(1000,1000,1000)
-- 封装redis关闭连接函数,实则是将其放入连接池
local function close_redis(red)
local pool_max_idle_time = 1000 --连接空闲时间,单位毫秒
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)
-- 连接redis
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
-- 关闭redis连接
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,"请求不存在:",path,", args:",params)
end
return resp.body
end
-- 将方法导出
local _M = {
read_http = read_http,
read_redis = read_redis
}
return _M
6.2 创建product.lua脚本,实现请求数据的处理
product.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
-- 封装查询
local 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("192.168.128.128",6379,key)
--判断redis查询结果
if not val then
ngx.log(ngx.ERR,"redis查询失败,尝试查询http, key: ", key)
-- redis查询失败,去查http
ngx.log(ngx.ERR,"path: ", path)
val = read_http(path,params)
end
end
-- 查询结束
if val then
-- 将结果加入本地缓存,并设置过期时间
item_cache:set(key,val,expire)
end
-- 返回数据
return val
end
--获取路径参数
local id = ngx.var[1]
--查询商品信息
local product =read_data("prod:id:" .. id, 60000, "/product/" .. id , nil)
--local productJson = read_http("/product/" .. id , nil)
--JSON转化为lua的table
-- local product = cjson.decode(productJson)
-- 修改product属性值
-- product.stock = 99
-- 把product序列化为json
-- local strJson = cjson.encode(product)
-- 返回结果
ngx.say(product)
7. 编辑/local/openresty/nginx/html/index.html文件,实现异步请求商品数据
拷贝jquery脚本到html目录如下
index.html中增加如下内容
<div id="div1" style="border:1px solid #cccccc; width:500px; height:300px;">
</div>
<script src="jquery-3.6.0.min.js"></script>
<script type="text/javascript">
$(document).ready(function(){
var url = location.search;
var id=1;
if(url.indexOf('?')>-1){
id=url.split('=')[1]
}
$.ajax({
type : 'GET',
url : "/api/item/"+id,
contentType : "application/json",
dataType : "json"
}).done(function(data) {
$("#div1").html(JSON.stringify(data));
});
});
</script>
8. 验证
8.1 启动服务器
8.2 启动nginx
进入目录 /local/openresty/nginx/sbin
执行 ./nginx -s start 或者 ./nginx -s reload
8.3 浏览器访问nginx
8.4 说明
可以通过在服务器端加断点或日志,和访问nginx的日志跟踪数据来源自哪个缓存
product数据改动后服务器端打印的日志
9. OpenResty补充说明
OpenResty提供了各种API用来获取不同类型的请求参数:
lua脚本中ngx.say(product)就是写数据到Response中
lua基本语法请参考 - lua基础使用说明 https://www.cnblogs.com/big-strong-yu/p/17075058.html