多级缓存快速上手

哈喽~大家好,这篇来看看多级缓存。

🥇个人主页:个人主页​​​​​

🥈 系列专栏:【微服务】

🥉与这篇相关的文章:

|-------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| JAVA进程和线程 | JAVA进程和线程-CSDN博客 |
| HttpClient 入门使用示例 | HttpClient 入门使用示例-CSDN博客 |
| Spring Task 快速入门 | Spring Task 快速入门-CSDN博客 |

目录

一、前言

1、什么是多级缓存?

2、集群模式

3、前期准备

二、Caffeine

1、什么是Caffeine?

2、缓存使用的基本API

2.1、基于大小设置驱逐策略

2.2、基于时间设置驱逐策略

三、实现多级缓存

1、前期准备

2、反向代理流程

3、OpenResty监听请求

4、代码解析

4.1、获取参数的API

4.2、查询Tomcat

4.3、CJSON工具类

4.4、基于ID负载均衡

4.5、Redis缓存预热

四、缓存同步

1、数据同步策略

2、监听Canal


一、前言

1、什么是多级缓存?

传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库,这个是没有问题的,但是这存在一些问题(请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈 ;Redis缓存失效时,大量的数据操作会对数据库产生冲击 )。

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

  • 浏览器访问静态资源时,优先读取浏览器本地缓存

  • 访问非静态资源(ajax查询数据)时,访问服务端

  • 请求到达Nginx后,优先读取Nginx本地缓存

  • 如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat)

  • 如果Redis查询未命中,则查询Tomcat

  • 请求进入Tomcat后,优先查询JVM进程缓存

  • 如果JVM进程缓存未命中,则查询数据库

在多级缓存架构中,nginx是一个编写业务的Web服务器,不是作为反向代理的服务器了。

2、集群模式

也就是说,nginx与tomcat服务要部署为集群模式。

3、前期准备

准备好需要的素材,部署好nginx(注:将其拷贝到一个非中文目录下 ),打开conf里面的nginx.conf配置文件,编写好关键配置(nginx集群的ip地址:端口号;监听/api路径,反向代理到nginx集群)。

此时 192.168.227.131 是我虚拟机的ip地址(这里你写的时候记得换上自己的)

二、Caffeine

1、什么是Caffeine?

Caffeine 是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine。GitHub地址:GitHub - ben-manes/caffeine: A high performance caching library for Java

缓存在日常开发中启动至关重要的作用 ,能大量减少对数据库的访问,减少数据库的压力 ,我们把缓存分为两类:

  • 分布式缓存,例如Redis:

    • 优点:存储容量更大、可靠性更好、可以在集群间共享

    • 缺点:访问缓存有网络开销

    • 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享

  • 进程本地缓存,例如HashMap、GuavaCache:

    • 优点:读取本地内存,没有网络开销,速度更快

    • 缺点:存储容量有限、可靠性较低、无法共享

    • 场景:性能要求较高,缓存数据量较小

我们的思路是:当我们的请求到nginx中,首先先查询本地缓存,当本地缓存没有时,再去查询redis,redis没有时,再去查询jvm进程,当这些都没有命中时,再最后查数据库。

2、缓存使用的基本API

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

    // 存数据
    cache.put("gf", "ddf");

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

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

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

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

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

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

2.1、基于大小设置驱逐策略

java 复制代码
    @Test
    void testEvictByNum() throws InterruptedException {
        // 创建缓存对象
        Cache<String, String> cache = Caffeine.newBuilder()
                // 设置缓存大小上限为 1
                .maximumSize(1)
                .build();
        // 存数据
        cache.put("gf1", "a");
        cache.put("gf2", "b");
        cache.put("gf3", "c");
        // 延迟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"));
    }

2.2、基于时间设置驱逐策略

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

三、实现多级缓存

1、前期准备

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

下载与安装步骤这里就不做过多的描述了,OpenResty底层是基于Nginx的,查看OpenResty目录的nginx目录,所以运行方式与nginx基本一致:

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

修改/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;
        }
    }
}

2、反向代理流程

打开案例,他的请求路径是这个:【微服务】

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

3、OpenResty监听请求

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

修改/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")做路径映射,而返回类型就是json。

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

/usr/loca/openresty/nginx目录创建文件夹:lua;在/usr/loca/openresty/nginx/lua文件夹下,新建文件:item.lua。

item.lua代码

java 复制代码
-- 导入common函数库
local common = require('common')
local read_http = common.read_http
local read_redis = common.read_redis
-- 导入cjson库
local cjson = require('cjson')
-- 导入item_cache
local item_cache = ngx.shared.item_cache

-- 封装查询函数
function read_data(key, expire,  path, params)
	local var = item_cache:get(key)
	if not var then
		ngx.log(ngx.ERR, "本地缓存查询失败,尝试查询redis, key: ", key)
	    -- 查询redis缓存
	    var = read_redis("127.0.0.1", 6379, key)
	    -- 判断查询结果
	    if not var then
	        ngx.log(ngx.ERR, "redis查询失败,尝试查询http, key: ", key)
	        -- redis查询失败,去查询http
	        var = read_http(path, params)
	    end
	end
	-- 查询成功,根据不同的数据设置不同的缓存时间,并且写入到本地缓存
	item_cache:set(key, var, expire)
	-- 返回数据
    return var
end

-- 获取路径参数
local id = ngx.var[1]

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

-- JSON转化为lua的table
local item = cjson.decode(itemJSON)
local stock = cjson.decode(stockJSON)
-- 组合数据
item.stock = stock.stock
item.sold = stock.sold

-- 把item序列化为json 返回结果
ngx.say(cjson.encode(item))

在nginx.cpnf里面添加

java 复制代码
		# 添加反向代理,到windows的Java服务
		# 该指令是用来设置代理服务器的地址,可以是主机名称,IP地址加端口号等形式。
		location /item {
    			proxy_pass http://tomcat-cluster;
		}
java 复制代码
     upstream tomcat-cluster{
    		hash $request_uri;
    		server 192.168.177.196:8081;
    		server 192.168.177.196:8082;
     }

common.lua 代码

java 复制代码
-- 导入redis
local redis = require("resty.redis")
-- 初始化 redis
local red = redis:new()
red:set_timeouts(1000, 1000, 1000)

-- 关闭redis连接的工具方法,其实是放入连接池
local function close_redis(red)
    local pool_max_idle_time = 10000 -- 连接的空闲时间,单位是毫秒
    local pool_size = 100 --连接池大小
    local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)
    if not ok then
        ngx.log(ngx.ERR, "放入redis连接池失败: ", err)
    end
end

-- 查询redis的方法 ip和port是redis地址,key是查询的key
local function read_redis(ip, port, key)
    -- 获取一个连接
    local ok, err = red:connect(ip, port)
    if not ok then
        ngx.log(ngx.ERR, "连接redis失败 : ", err)
        return nil
    end
    -- 查询redis
    local resp, err = red:get(key)
    -- 查询失败处理
    if not resp then
        ngx.log(ngx.ERR, "查询Redis失败: ", err, ", key = " , key)
    end
    --得到的数据为空处理
    if resp == ngx.null then
        resp = nil
        ngx.log(ngx.ERR, "查询Redis数据为空, key = ", key)
    end
    close_redis(red)
    return resp
end

-- 封装函数,发送http请求,并解析响应( ngx.location.capture)
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

然后重新加载配置:nginx -s reload。

4、代码解析

4.1、获取参数的API

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

location ~ /api/item/(\d+) {

默认的响应类型

default_type application/json;

响应结果由lua/item.lua文件来决定

content_by_lua_file lua/item.lua;

}

里面的 ~ /api/item/(\d+) 对应的就是 http://localhost/api/item/10003 (前端发来的路径,这里拿到了商品的id)

4.2、查询Tomcat

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

发送http请求的API

举个例子:

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://你自己的ip:8081; 
 }

在item.lua文件当中,有这一串:

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)
ngx.say(itemStockJSON )

他的作用是接受到请求路径,然后根据id来查询数据库,返回json数据。

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

这就需要我们先把JSON变为lua的table,完成数据整合后,再转为JSON(序列化与反序列化)。

4.3、CJSON工具类

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

举个例子:

引入cjson模块:

java 复制代码
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
-- 导入cjson库
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(stockJSON)

-- 组合数据
item.stock = stock.stock
item.sold = stock.sold

-- 把item序列化为json 返回结果
ngx.say(cjson.encode(item))

4.4、基于ID负载均衡

刚才的代码中,我们的tomcat是单机部署。而实际开发中,tomcat一定是集群模式,因此,OpenResty需要对tomcat集群做负载均衡。

如何做?

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

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

思路

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

举个例子

  • 我们的请求路径是 /item/10001

  • tomcat总数为2台(8081、8082)

  • 对请求路径/item/1001做hash运算求余的结果为1

  • 则访问第一个tomcat服务,也就是8081

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

在nginx.conf文件里面添加这一段(hash $request_uri;)

复制代码
     upstream tomcat-cluster{
    		hash $request_uri;
    		server 192.168.177.196:8081;
    		server 192.168.177.196:8082;
     }

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

复制代码
location /item {
    proxy_pass http://tomcat-cluster;
}

重新加载OpenResty

复制代码
nginx -s reload

4.5、Redis缓存预热

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

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

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

由于数据较少所以这里将所有的数据都存入缓存中。

具体代码

java 复制代码
@Component
public class RedisHandler implements InitializingBean {

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private IItemService itemService;
    @Autowired
    private IItemStockService itemStockService;

    /**
     * Jackson提供了ObjectMapper来供程序员"定制化控制"序列化、反序列化的过程。
     * objectMapper在调用writeValue()序列化 或 调用readValue()反序列化方法之前,
     * 往往需要设置 ObjectMapper 的相关配置信息,这些配置信息作用在 java 对象的所有属性上,
     * 表示在进行序列化和反序列化时进行一些特殊的处理。
     */
    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public void afterPropertiesSet() throws Exception {
        // 查询商品
        List<Item> itemList = itemService.list();
        // 商品集合序列化,存入redis
        for (Item item : itemList) {
            String itemJson = MAPPER.writeValueAsString(item);
            redisTemplate.opsForValue().set("item:id:" + item.getId(), itemJson);
        }

        // 查询库存
        List<ItemStock> stockList = itemStockService.list();
        // 库存集合序列化,存入redis
        for (ItemStock stock : stockList) {
            String stockJson = MAPPER.writeValueAsString(stock);
            redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), stockJson);
        }

    }

    public void save(Item item){
        try {
            String itemJson = MAPPER.writeValueAsString(item);
            redisTemplate.opsForValue().set("item:id:" + item.getId(), itemJson);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    public void delete(Long id){
        redisTemplate.delete("item:id:" + id);
    }

}

InitializingBean接口为bean提供了初始化方法的方式,它只包括afterPropertiesSet方法,凡是继承该接口的类,在初始化bean的时候都会执行该方法。

ObjectMapper:Jackson提供了ObjectMapper来供程序员"定制化控制"序列化、反序列化的过程。objectMapper在调用writeValue()序列化 或 调用readValue()反序列化方法之前,往往需要设置 ObjectMapper 的相关配置信息,这些配置信息作用在 java 对象的所有属性上,表示在进行序列化和反序列化时进行一些特殊的处理。

四、缓存同步

大多数情况下,浏览器查询到的都是缓存数据,当我们管理员修改数据时,缓存没有及时更新,这就会出大问题了。

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

1、数据同步策略

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

  • 优势:简单、方便

  • 缺点:时效性差,缓存过期之前可能不一致

  • 场景:更新频率较低,时效性要求低的业务

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

  • 优势:时效性强,缓存与数据库强一致

  • 缺点:有代码侵入,耦合度高;

  • 场景:对一致性、时效性要求较高的缓存数据

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

  • 优势:低耦合,可以同时通知多个缓存服务

  • 缺点:时效性一般,可能存在中间不一致状态

  • 场景:时效性要求一般,有多个服务需要同步

这里我们使用Canal(基于Canal的通知 )

2、监听Canal

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

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

引入依赖

java 复制代码
<dependency>
    <groupId>top.javatool</groupId>
    <artifactId>canal-spring-boot-starter</artifactId>
    <version>1.2.1-RELEASE</version>
</dependency>

编写配置

java 复制代码
canal:
  destination: heima # canal的集群名字,要与安装canal时设置的名称一致
  server: 192.168.150.101:11111 # canal服务地址

修改实体类

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

@TableName("tb_item"):要监听的表名

@Id:告诉他谁是id(主键)

@Column(name = "name"):当DB里面的字段与实体类对应不上时,用name对应。

@Transient:告诉它,谁不是表中的字段。

编写监听器

通过实现EntryHandler<T>接口编写监听器,监听Canal消息。注意两点:

  • 实现类通过@CanalTable("tb_item")指定监听的表信息

  • EntryHandler的泛型是与表对应的实体类

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

不积跬步无以至千里,趁年轻,使劲拼,给未来的自己一个交代!向着明天更好的自己前进吧!

相关推荐
海兰14 小时前
【高速缓存】RedisVL 高级查询(全文搜索、混合搜索和 多向量搜索)
数据库·人工智能·redis·缓存
流星白龙1 天前
【Redis】4.基本全局命令
数据库·redis·缓存
流星白龙1 天前
【Redis】3.Redis安装与命令行客户端
数据库·redis·缓存
我不想名字重复1 天前
redis缓存和数据库数据保持一致
数据库·redis·缓存
时空无限2 天前
vllm 大模型启动缓存相关环境变量 export
linux·缓存·vllm
流星白龙2 天前
【Redis】6.计数其他命令
数据库·redis·缓存
正儿八经的少年2 天前
redis 的大 key 和热 key 详解
数据库·redis·缓存
X-⃢_⃢-X2 天前
十、Redis之布隆过滤器
数据库·redis·缓存
Database_Cool_2 天前
阿里云 Tair(企业级内存数据库)vs 腾讯云 Redis 云缓存深度对比:性能/数据结构/成本全维度 Benchmark
数据库·阿里云·缓存
流星白龙2 天前
【Redis】1.Redis特性
数据库·redis·缓存