从零开始学习Redis(五):多级缓存

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

1· 请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈

Redis缓存失效时,会对数据库产生冲击

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

用作缓存的Nginx是业务Nginx,需要部署为集群,再有专门的Nginx来做反向代理。

请求大多在Nginx处理,在Nginx查redis,避免对Tomcat性能造成影响,压力给到了Nginx

当redis缓存失效时,数据不会直接打到数据库,而是先到Tomcat读本地进程缓存

我们要在Tomcat编写进程缓存(JVM进程缓存 ),在Nginx内部编程,需要学习Lua语言 ,之后就可以实现多级缓存缓存同步策略了。

JVM进程缓存

缓存由于存储在内存中,数据读取速度非常快,能大量减少对数据库的访问压力。

缓存分两类:

  • 分布式缓存,例如Redis:
    • 优点:Redis是独立在Tomcat之外的,可以在集群间共享;Redis自己也有集群模式,存储容量更大;Redis的主从模式和哨兵机制保证了更好的可靠性
    • 缺点:因为Redis是独立在Tomcat之外的,Tomcat访问Redis有网络开销,性能受制于网络
    • 场景:缓存数据量大,可靠性要求高,集群共享
  • 进程本地缓存,如HashMap,GuavaCache:
    • 优点:读取本地内存,无网络开销,速度更快
    • 缺点:存储容量有限,可靠性低,无法在Tomcat共享
    • 场景:性能要求高,缓存数据量小

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

Caffeine基本使用:

Caffeine的缓存过期(驱逐)策略

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

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

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

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

配置商品和库存的本地进程缓存,给查询商品和库存业务添加缓存,缓存未命中时查数据库

Lua常用语法

以前我们用Tomcat+Java做业务开发,现在我们在Nginx的业务集群实现本地缓存,需要使用Nginx+Lua做业务开发。

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

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

运行脚本 lua xx.lua

利用type函数测试给定变量或值的类型:print(type("hello world")) ,结果是string

Lua声明变量时不需要指定数据类型

Lua 复制代码
--声明字符串
local str='hello'
-- 拼接字符串用..
local str1='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循环遍历

Lua 复制代码
--遍历数组
local arr={'java','python','lua'}
for index,value in ipairs(arr) do
    print(index,value)
end


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

函数

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

--例如,定义一个用来打印数组的函数
local function printArr(arr)
    for index,value in ipairs(arr) do
        print(value)
    end
end

条件控制,类似java的条件控制,如if,else

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

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

and相当于&&,or相当于||,not相当于!

Lua 复制代码
--自定义一个函数,可以打印table,当参数为nil时,打印错误信息
local function  printArr(arr)
    if(not arr) then
        print('数组不能为空')
        return nil
    end
    for index,value in ipairs(arr) do
        print(value)
    end
end

多级缓存

我们在Nginx里完成查询Redis,查询Tomcat等业务是依赖于OpenResty完成的

部署OpenResty

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

· 具备Nginx的完整功能

· 基于Lua语言进行扩展,集成了大量精良的Lua库、第三方模块

· 允许使用Lua自定义业务逻辑、自定义库

官方网站:https://openresty.org/cn/

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

|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| 参数格式 | 参数示例 | 参数解析代码示例 |
| 路径占位符 | /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() |

查询Tomcat

部署完Nginx后,我们不先查Redis缓存,因为服务刚启动时Redis缓存里没有缓存,还是要去Tomcat里查,因此我们先查询Tomcat,而查询Tomcat缓存需要发送Http请求

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

Lua 复制代码
local resp = nginx.location.capture("/path",{ 
    method=ngx.HTTP_GET, --请求方式
    args={a=1,b=2}, --get方式传参数
    body="c=3&d=4"  --post方式传参数
})

--path是路径,不包含IP和端口,这个请求会被nginx内部的server监听并处理,但是我们是要把这个请求发到Tomcat服务器,所以还要编写一个server来对此路径做反向代理

location /path{
    --这里是windows电脑的ip和java服务端口,要确保windows防火墙处于关闭状态
    proxy_pass http://192.168.100.1:8081; --tomcat的端口
}

返回的响应内容包括:

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

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

1 在/usr/local/openresty/lualib目录下创建commin.lua文件

Lua 复制代码
vi /usr/local/openresty/lualib/common.lua

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

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

返回数据我们要将JSON对象转为lua的table,类似与JSON转JAVA的对象,OpenResty提供了一个cjson的模块用来处理JSON的序列化和反序列化

Lua 复制代码
--1 引入cjson模块
local cjson=require "cjson"

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

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

刚才我们是利用OpenResty向一个Tomcat发送请求,但在实际情况下,tomcat肯定是个集群,所以OpenResty发请求时要对tomcat集群实现负载均衡

1 tomcat集群配置

我们要保证同一个id的商品永远访问同一台tomcat,因为进程缓存是不共享的,而nginx默认的负载均衡模式轮询无法满足这个需求,所以要修改nginx负载均衡算法。

Lua 复制代码
upstream tomcat-cluster{
    --对请求路径做hash运算,得到的结果对tomcat数量取模判断访问哪一台服务器,之后访问只要数值是相同的,就能保证相同数据永远访问同一台服务器
    hash $request_uri; 
    server 192.168.100.1:8081;
    server 192.168.100.1:8082;
}

2 反向代理配置 将/item路径的请求代理到tomcat集群

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

OpenResty在实际开发中也不止一台,和Tomcat一样,我们也需要在Nginx反向代理上配置OpenResty的集群,并且修改负载均衡算法。

Lua 复制代码
upstream nginx-cluster{
    hash $request_uri; 
    server 192.168.150.101:8081;
    server 192.168.150.101:8082;
    server 192.168.150.101:8083;
}

Redis缓存预热

之前我们直接查Tomcat是因为redis里没有缓存,根据多级缓存的思想,现在我们要添加redis缓存,Nginx请求优先查询redis,redis缓存未命中再查询tomcat

但redis有一个问题:

冷启动:服务刚刚启动时,Redis里没有缓存,如果所有数据都在第一次查询是添加缓存,会给数据库带来巨大压力,因此我们在数据启动时先做缓存预热

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

这里我们给商品和库存添加Redis缓存预热

1 利用docker安装redis

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

2 在item-service服务中引入依赖

3 配置redis地址(虚拟机ip地址)

4 配置初始化类

查询Redis缓存

缓存预热完成后,就可以查询redis缓存了,OpenResty提供了操作redis的模块,直接引入就可使用

1 引入Redis模块,初始化Redis对象

Lua 复制代码
--引入redis模块
local redis=require("resty.redis")
--初始化redis对象
local red=redis:new()
--设置redis超时时间
red:set_timeouts(1000,1000,1000)

2 封装函数,用来释放redis连接

Lua 复制代码
-- 关闭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 封装函数,从redis读数据并返回

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

Nginx本地缓存

我们已经实现了tomcat的jvm缓存,实现了从openresty到tomcat的负载均衡远程调用,实现了先查redis,redis未命中再查tomcat,现在还需要在OpenResty搭建本地缓存

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

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

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

2 操作共享字典

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

流程:优先查本地缓存,未命中查redis缓存,redis未命中查tomcat,查询redis或tomcat成功后,将数据写入本地缓存,并设置有效期

Lua 复制代码
--查询函数
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

缓存同步策略

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

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

我们以前使用MQ发送异步通知,MQ虽然可以实现异步通知,但仍需要修改业务发消息,具有一定侵入性。这里我们学习一个新的异步通知方法:基于Canal的异步通知,可以监听mysql的变化,代码侵入性更低

Canal,译意为水道/管道,是一款开源项目,基于Java开发。基于数据库增量日志解析,提供增量数据订阅&消费。

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

· MySQL master 将数据变更写入二进制日志(binary log),其中记录的数据叫做binary log events

· MySQL slave 将master的binary log events拷贝到它的中继日志(relay log)

· MySQL slave重放relay log 中事件,将数据变更反映它自己的数据

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

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

1 引入依赖

2 编写配置

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

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());
    }
}

通过注解声明数据库表与实体类的映射关系

多级缓存的总体流程

我们将一个item.html页面放在Windows的nginx上,作为静态资源服务器和反向代理服务器,当用户访问浏览器,nginx将页面返回给用户,

用户发送请求查询数据,Nginx将请求反向代理给OpenResty集群,**OpenResty集群优先从本地缓存查询,**因为OpenResty集群之间不共享数据,所有我们要修改负载均衡算法,不再是轮询,而是根据请求的url,url里包含请求数据的id,url不变,id不变,数据不变,保证同一数据访问到同一OpenResty,

OpenResty未命中查询Redis

Redis未命中访问Tomcat集群的进程缓存,而进程缓存也不共享数据,因此我们在这里也要修改对Tomcat的负载均衡算法,

进程缓存未命中再去查数据库

这样就形成了 本地缓存------Redis缓存------Tomcat进程缓存 的多级缓存架构。

此外,当数据库数据发生修改,本地缓存,Redis缓存和进程缓存都要做数据同步

在OpenResty本地缓存里我们设置超时同步,到期自动删除,适合存数据更新频率较低的数据;

对于Redis和进程缓存就可以放任意类型的数据了,要保证它们的时效性,我们使用Canal监听数据库,数据库一旦发生修改,Canal发送通知给Java客户端,Java客户端立即去修改Redis缓存和进程缓存。

相关推荐
Javatutouhouduan7 小时前
记一次redis主从切换导致的数据丢失与陷入只读状态故障
java·redis·设计模式·java面试·高可用·java后端·java程序员
无关86887 小时前
Redis BigKey场景实战
redis
JavaEdge.7 小时前
榨干 CPU 性能:通过绑核将 Redis 尾延迟减半!
数据库·redis·缓存
YDS8298 小时前
Redis入门 —— 基本数据类型和Spring Data Redis
数据库·redis·spring
一个儒雅随和的男子8 小时前
Redis大Key调优指针
数据库·redis·缓存
Chunyyyen8 小时前
【第二十周】自然语言处理的学习笔记05
笔记·学习·自然语言处理
笨鸟笃行8 小时前
百日挑战——单词篇(第十一天)
学习
yong158585534310 小时前
1. Linux C++ muduo 库学习——库的编译安装
linux·c++·学习
9523610 小时前
数据结构-顺序表
java·数据结构·学习