从零开始学习Redis(五):多级缓存
传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库,存在下面的问题:
1· 请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈
2· 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声明变量时不需要指定数据类型
--声明字符串
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循环遍历
--遍历数组
local arr={'java','python','lua'}
for index,value in ipairs(arr) doprint(index,value)
end--遍历table
local map={name='Jack',age=21}
for key,value in pairs(map) doprint(key,value)
end函数
--定义函数
function 函数名(argument1,argument2...,argumentn)--函数体return 返回值
end--例如,定义一个用来打印数组的函数
local function printArr(arr)for index,value in ipairs(arr) doprint(value)end
end条件控制,类似java的条件控制,如if,else
if(布尔表达式)
then--[布尔表达式为true时执行该语句块]
else--[布尔表达式为false时执行该语句块]
end与java不同的是,布尔表达式中的逻辑运算是基于英文单词
and相当于&&,or相当于||,not相当于!
--自定义一个函数,可以打印table,当参数为nil时,打印错误信息
local function printArr(arr)if(not arr) thenprint('数组不能为空')return nilendfor index,value in ipairs(arr) doprint(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 | -- 读取请求体 -- 获取POST表单参数,返回值是table类型 |
| JSON参数 | {"id": 1001} | -- 读取请求体 ngx.req.read_body() |
查询Tomcat
部署完Nginx后,我们不先查Redis缓存,因为服务刚启动时Redis缓存里没有缓存,还是要去Tomcat里查,因此我们先查询Tomcat,而查询Tomcat缓存需要发送Http请求
nginx提供了内部API用以发送http请求:
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文件
vi /usr/local/openresty/lualib/common.lua2 在common.lua中封装http查询的函数
-- 封装函数,发送http请求,并解析响应
local function read_http(path, params)local resp = ngx.location.capture(path,{method = ngx.HTTP_GET,args = params,})if not resp then-- 记录错误信息,返回404ngx.log(ngx.ERR, "http not found, path: ", path , ", args: ", args)ngx.exit(404)endreturn resp.body
end
-- 将方法导出
local _M = { read_http = read_http
}
return _M返回数据我们要将JSON对象转为lua的table,类似与JSON转JAVA的对象,OpenResty提供了一个cjson的模块用来处理JSON的序列化和反序列化
--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负载均衡算法。
upstream tomcat-cluster{--对请求路径做hash运算,得到的结果对tomcat数量取模判断访问哪一台服务器,之后访问只要数值是相同的,就能保证相同数据永远访问同一台服务器hash $request_uri; server 192.168.100.1:8081;server 192.168.100.1:8082;
}2 反向代理配置 将/item路径的请求代理到tomcat集群
location /item{proxy_pass http://tomcat-cluster;
}OpenResty在实际开发中也不止一台,和Tomcat一样,我们也需要在Nginx反向代理上配置OpenResty的集群,并且修改负载均衡算法。
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对象
--引入redis模块
local redis=require("resty.redis")
--初始化redis对象
local red=redis:new()
--设置redis超时时间
red:set_timeouts(1000,1000,1000)2 封装函数,用来释放redis连接
-- 关闭redis连接的工具方法,其实是放入连接池
local function close_redis(red)local pool_max_idle_time = 10000 -- 连接的空闲时间,单位是毫秒local pool_size = 100 --连接池大小local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)if not ok thenngx.log(ngx.ERR, "放入redis连接池失败: ", err)end
end3 封装函数,从redis读数据并返回
-- 查询redis的方法 ip和port是redis地址,key是查询的key
local function read_redis(ip, port, key)-- 获取一个连接local ok, err = red:connect(ip, port)if not ok thenngx.log(ngx.ERR, "连接redis失败 : ", err)return nilend-- 查询redislocal resp, err = red:get(key)-- 查询失败处理if not resp thenngx.log(ngx.ERR, "查询Redis失败: ", err, ", key = " , key)end--得到的数据为空处理if resp == ngx.null thenresp = nilngx.log(ngx.ERR, "查询Redis数据为空, key = ", key)endclose_redis(red)return resp
endNginx本地缓存
我们已经实现了tomcat的jvm缓存,实现了从openresty到tomcat的负载均衡远程调用,实现了先查redis,redis未命中再查tomcat,现在还需要在OpenResty搭建本地缓存
OpenResty为Nginx提供了shard dict的功能,可以在nginx的多个worker之间共享数据(多个OpenResty之间不共享),实现缓存功能。
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')流程:优先查本地缓存,未命中查redis缓存,redis未命中查tomcat,查询redis或tomcat成功后,将数据写入本地缓存,并设置有效期

--查询函数
function read_data(key,expire,path,params)--查询本地缓存local val=item_cache:get(key)if not val thenngx.log(ngx.ERR,"本地缓存查询失败,尝试查询Redis,key:",key)--查询Redisval=read_redis("127.0.0.1",6379,key)if not val thenngx.log(ngx.ERR,"Redis缓存查询失败,尝试查询http,key:",key)--redis查询失败,去查httpval=read_http(path,params)endend--查询成功,把数据写入本地缓存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消息
@CanalTable("tb_item") //指定要监听的表
@Component
public class ItemHandler implements EntryHandler<Item> { //指定表关联的实体类@Autowiredprivate RedisHandler redisHandler;@Autowiredprivate Cache<Long,Item> itemCache;//监听到数据库的增的消息@Overridepublic void insert(Item item) {//写数据到JVM进程缓存itemCache.put(item.getId(), item);//写数据到redisredisHandler.saveItem(item);}//监听到数据库的改的消息@Overridepublic void update(Item before, Item after) {//写数据到JVM进程缓存itemCache.put(after.getId(), after);//写数据到redisredisHandler.saveItem(after);}//监听到数据库的删的消息@Overridepublic 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缓存和进程缓存。


