接下来继续学习Redis的高级篇,内容包含:分布式缓存 、多级缓存 、Redis最佳实践 相关内容
本篇主要是介绍Redis的多级缓存,为了减轻了 Redis 的网络 I/O 压力和 Tomcat(业务应用)的 CPU/处理压力,具体有:JVM进程缓存 ,Lua语法 ,多级缓存实现(OpenResty)以及缓存同步(Canal)
欢迎大家一起学习,共同进步🥰
文章目录
2.多级缓存
传统缓存的问题:
传统的缓存策略一般是请求到Tomcat后,先查Redis,如果未命中则查询数据库,存在的问题:
- 请求要通过Tomcat处理,Tomcat的性能成为整个系统的瓶颈
- Redis缓存失效时,会对数据库产生冲击

多级缓存:
多级缓存的核心思想是构建一个纵深防御体系,在请求处理的每个环节都设置缓存层,让数据离用户更近、读取更快,从而极大地提升系统性能。

- 浏览器/客户端缓存
- 位置:用户的浏览器或App本地。
- 作用:作为第一道防线,直接缓存静态资源(如图片、CSS、JS文件)。对于未过期的资源,请求甚至不会发送到服务器,极大地减少了网络传输和服务器压力。
- CDN缓存 (内容分发网络)
- 位置:分布在全球各地的边缘服务器。
- 作用:将网站的静态内容分发到离用户地理位置最近的节点。当用户请求资源时,直接从最近的CDN节点获取,显著降低访问延迟。
- Nginx本地缓存
- 位置:网关或反向代理服务器(如集成Lua的OpenResty)。
- 作用:这是多级缓存架构的关键升级。请求在进入应用服务器之前,会先经过Nginx。如果Nginx本地缓存命中,可直接返回数据,请求完全不会进入后端的Tomcat等应用集群,极大地减轻了应用服务器的压力。
- 分布式缓存 (如Redis)
- 位置:独立的缓存服务器集群。
- 作用:这是传统缓存架构的主力。它容量大,可以被所有应用服务器共享,用于存储热点业务数据、用户会话等。
- 应用进程缓存 (如JVM缓存)
- 位置:应用服务器自身的内存中(如使用Caffeine、Guava Cache)。
- 作用:速度最快(无网络开销),作为最后一道屏障。当分布式缓存出现故障或网络抖动时,它可以防止请求直接击穿到数据库,起到"救命"的作用。
2.1JVM进程缓存
缓存在日常开发中起至关重要的作用,由于是存储在内存中,数据的读取速度非常快,能大量减少对数据库的访问,减少数据库的压力,可以把缓存分为两类:
- 分布式缓存 eg:Redis
- 优点:存储容量更大,可靠性更高,可以在集群间共享
- 缺点:访问缓存有网络开销
- 场景:缓存数据量较大,可靠性要求较高,需要在集群间共享
- 进程本地缓存 eg:HashMap
- 优点:读取本地内存,没有网络开销,速度更快
- 缺点:存储容量有限,可靠性较低,无法共享
- 场景:性能要求较高,缓存数据量较小
2.1.1初识Caffeine
Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前spring内部的缓存使用的就是Caffeine。
引入依赖:
xml
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version> <!-- 建议使用最新稳定版本 -->
</dependency>
示例:
- 创建caffeine实例
- 存数据
put - 取数据
getIfPresent,get
java
@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);
}
Caffeine的三种缓存驱逐策略:
Caffeine 的缓存驱逐机制是其高性能的核心保障。简单来说,它通过自动管理内存,防止缓存无限增长导致内存溢出(OOM),同时确保留在缓存里的都是最有价值的数据。
-
基于容量:设置缓存的数量上限
javaCache<String, String> shopCache = Caffeine.newBuilder() .maximumSize(1)//缓存大小上限为1 .build(); -
基于时间:设置缓存的有效时间
javaCache<String, String> shopCache = Caffeine.newBuilder() .expireAfterWrite(Duration.ofSeconds(10))//有效期为十秒 .build(); -
基于引用:设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用
默认情况下,当一个缓存元素过期时,caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐
原因:
避免精准定时带来的巨大开销
caffeine不关心数据"精确"在几毫秒过期。它只关心:"当你下次来访问(读/写)这个数据时,我发现它已经过期了,那我就顺手把它删掉。"
保证读写操作的无锁和极速
节省CPU资源
2.1.2实现进程缓存

1.初始化缓存,用Bean注释将它交给Spring容器管理,设置参数
java
@Configuration
public class CaffeineConfig {
//商品缓存
@Bean
public Cache<Long, Item> itemCache() {
return Caffeine.newBuilder()
.initalCapacity(100)
.maximumSize(10000)
.build();
}
//库存缓存
@Bean
public Cache<Long, ItemStock> stockCache() {
return Caffeine.newBuilder()
.initalCapacity(100)
.maximumSize(10000)
.build();
}
}
2.具体业务实现
注入缓存
java
@Autowired
private Cache<Long,Item> itemCache;
@Autowired
private Cache<Long,ItemStock> stockCache;
Spirng通过泛型类型区分Bean
java
@GetMapping("/{id}")
public Item findById(@PathVariable("id") Long id) {
return itemCache.get(id, key -> itemService.query()
.ne("status", 3).eq("id", key)
.one()
);
}
@GetMapping("/stock/{id}")
public ItemStock findStockById(@PathVariable("id") Long id) {
return stockCache.get(id, key -> stockService.getById(key));
}
2.2Lua语法
2.2.1初识Lua
Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供林国外的拓展和定制功能
快速入门:
-
在Linux虚拟机的任意目录下,新建一个hello.lua文件
bashtouch hello.lua -
添加lua脚本
luaprint("hello world") -
运行
bashlua hello.lua
进入lua控制台:lua
2.2.2变量和循环
数据类型:

测量变量类型:
type(...)
变量:
Lua声明变量时不需要指定变量类型
lua
-- 声明字符串
local str = 'hello'
-- 声明数字
local num = 21
-- 声明布尔类型
local flag = true
-- 声明数组 key为索引的 table
local arr = {'java', 'python', 'lua'}
-- 声明table,类似java的map
local map = {name='Jack', age=21}
| 特性 | 全局变量 | 加 local (局部变量) |
|---|---|---|
| 作用域 | 全局可见,所有文件、函数都能访问 | 仅在声明它的"块"内有效(如函数、循环、文件内部) |
| 生命周期 | 程序运行期间一直存在,直到被手动销毁 | 出了作用域(块结束)即被释放,由垃圾回收处理 |
| 性能 | 较慢(需要访问全局环境表 _G) |
极快(直接访问寄存器或栈) |
| 安全性 | 低(容易被其他代码意外覆盖或修改) | 高(外部无法访问,避免命名冲突) |
字符串拼接用:..
访问table:
lua
-- 访问数组,从角标1开始
print(arr[1])
-- 访问table
print(map['name'])
print(map.name)
数组、table都可以利用for循环来遍历:
- 遍历数组:
lua
-- 声明数组 key为索引的 table
local arr = {'java', 'python', 'lua'}
-- 遍历数组
for index,value in ipairs(arr) do
print(index, value)
end
- 遍历table:
lua
-- 声明map,也就是table
local map = {name='Jack', age=21}
-- 遍历table
for key,value in pairs(map) do
print(key, value)
end
2.2.3条件控制和函数
函数:
定义函数的语法:
lua
function 函数名( argument1, argument2..., argumentn)
-- 函数体
return 返回值
end
例如,定义一个函数,用来打印数组:
lua
function printArr(arr)
for index, value in ipairs(arr) do
print(value)
end
end
条件控制:
类似java的条件控制
lua
if(...)
then
-- true时执行
else
-- false时执行
end

2.3多级缓存
2.3.1OpenResty初识与安装
OpenResty是一个基于Nginx的高性能web平台,用于方便地搭建能够处理高并发、扩展性极高的动态web应用、web服务和动态网关
简单理解:OpenResty基于Nginx再加上一些插件
特点:
- 具备Nginx的完整功能
- 基于Lua语言进行扩展,集成了大量精良的Lua库,第三方模块
- 允许使用Lua自定义业务逻辑、自定义库
openresty/bin/下有openresty文件,是一个软链接,链向Nginx的可执行文件:

安装
1)安装开发库
首先要安装OpenResty的依赖开发库,执行命令:
sh
yum install -y pcre-devel openssl-devel gcc --skip-broken
2)安装OpenResty仓库
你可以在你的 CentOS 系统中添加 openresty 仓库,这样就可以便于未来安装或更新我们的软件包(通过 yum check-update 命令)。运行下面的命令就可以添加我们的仓库:
yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
如果提示说命令不存在,则运行:
yum install -y yum-utils
然后再重复上面的命令
3)安装OpenResty
然后就可以像下面这样安装软件包,比如 openresty:
bash
yum install -y openresty
4)安装opm工具
opm是OpenResty的一个管理工具,可以帮助我们安装一个第三方的Lua模块。
如果你想安装命令行工具 opm,那么可以像下面这样安装 openresty-opm 包:
bash
yum install -y openresty-opm
默认情况下,OpenResty安装的目录是:/usr/local/openresty
5)配置nginx的环境变量
打开配置文件:
sh
vi /etc/profile
在最下面加入两行:
sh
export NGINX_HOME=/usr/local/openresty/nginx
export PATH=${NGINX_HOME}/sbin:$PATH
NGINX_HOME:后面是OpenResty安装目录下的nginx的目录
然后让配置生效:
source /etc/profile
2.3.2OpenResty快速入门

具体步骤:
-
修改nginx.conf文件
-
在nginx.conf的http下面,添加对OpenResty的Lua模块的加载:
nginx# 加载lua 模块 lua_package_path "/usr/local/openresty/lualib/?.lua;;"; # 加载c模块 lua_package_cpath "/usr/local/openresty/lualib/?.so;;";这两行配置就是配置"搜索路径" 。虽然 OpenResty 默认通常已经配置好了这些路径,但在某些自定义环境或为了保险起见,显式地告诉 Nginx "去哪里加载 Lua 代码库" ,防止程序因为找不到依赖文件而崩溃
-
在nginx.conf的server下面,添加对/api/item这个路径的监听:
nginxlocation /api/item { # 响应类型,这里返回json default_type application/json; # 响应数据由 lua/item.lua这个文件来决定 content_by_lua_file lua/item.lua; }
-
-
编写item.lua文件
-
在nginx目录创建文件夹
luabashmkdir lua -
在lua文件夹下创建文件:item.lua
bashtouch lua/item.lualuangx.say('{"id":1001,"name":"SALSAS"}') -
重新加载配置
bashnginx -s reload
-
2.3.3请求参数处理

2.3.4查询Tomcat
Nginx内部提供了可以发送http请求的API:
lua
local resp = ngx.location.capture("/path",{
method = ngx.HTTP_GET, -- 请求方式
args = {a=1,b=2}, -- get方式传参数
body = "c=3&d=4" -- post方式传参数
})
返回的响应内容包括:
- resp.status:响应状态码
- resp.header:响应头,是一个table
- resp.body:响应体,就是响应数据
注意:这里的path是路径,并不包含IP和端口。这个请求会被nginx内部的server监听并处理
但是我们希望这个请求发送到Tomcat服务器,所以还需要编写一个server来对这个路径做反向代理:
nginx
location /path {
# 这里是windows电脑的ip和Java服务端口,需要确保windows防火墙处于关闭状态
proxy_pass http://192.168.150.1:8081;
}

封装请求函数
我们可以把http查询的请求封装为一个函数,放到OpenRe函数库中,方便后期使用。
1.在/usr/local/openresty/lualib目录下创建common.lua文件:
bash
vi /usr/local/openresty/lualib/common.lua
配置了查找路径,方便业务代码随时按需引用
后续执行
require "common"时,OpenResty就会加载这个文件
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
- 为什么要编写、封装、导出这三步?
- 因为加了
local,表示当前函数只在这个lua文件有效,外部无法使用- 那删去local行不行?
- 可以,但很危险。
- 如果去掉
local,这个函数就变成了全局函数- 坏处:
- 污染全局环境 :所有的脚本都能看到这个函数,变量名很容易冲突(比如你在 A 文件定义了
read_http,B 文件也定义了一个同名的,就会打架)- 性能略差:Lua 查找全局变量比查找局部变量(local)要慢一点点
发送请求
lua
-- 导入common函数库
local common = require('common')
local read_http = common.read_http
-- 导入cjson库
local cjson = require('cjson')
-- 获取路径参数
local id = ngx.var[1]
-- 查询商品信息
local itemJSON = read_http("/item/" .. id, nil)
-- 查询库存信息
local stockJSON = 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))
OpenResty提供了一个cjson的模块用于处理JSON的序列化和反序列化
·Tomcat的负载均衡

- 配置Tomcat集群
- 反向代理配置,将请求代理到Tomcat集群
如果不用 hash request_uri ,默认策略就是轮询?
OpenResty(准确说是底层的 Nginx)会严格地按照顺序,一个接一个地把请求发给后端的 Tomcat 节点
缺点:每台机器都要重复干活,缓存无法共享
使用了hash $request_uri后:相同的 URL 永远找同一台 Tomcat
目标服务器索引 = hash(请求特征) % 服务器总数
hash算法就是把任意长度的字符串(或数据),转换成一个固定长度的整数
eg:"abc" -----> 97 *31 *31 + 98 *31 + 99
2.3.5Redis缓存预热
冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力。
缓存预热:在实际开发中,我们可以利用大数据统计用户访问的热点数据,在项目启动时将这些热点数据提前查询并保存到Redis中。
我们数据量较少,可以在启动时将所有数据都放入缓存中。
eg:把商品信息缓存进Redis
java
@Component
public class RedisHandler implements InitializingBean {
@Autowired
private IItemService itemService;
@Autowired
private RedisTemplate redisTemplate;
private static final ObjectMapper MAPPER = new ObjectMapper();
// 在属性设置完成后执行
@Override
public void afterPropertiesSet() throws Exception {
// 初始化缓存
// 1.查询商品信息(从数据库全量拉取)
List<Item> itemList = itemService.list();
// 2.放入缓存(遍历写入Redis)
for (Item item : itemList) {
// 2.1.item序列化为JSON
String json = MAPPER.writeValueAsString(item);
// 2.2.存入redis
redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
}
}
}
implements InitializingBean:
- 这是一个 Spring 的接口。它的意思是:"当这个类被 Spring 创建好,并且所有属性都注入完成后,立即执行
afterPropertiesSet方法。
ObjectMapper
- Jackson 库里专门负责JSON 转换的工具。这里它的核心任务就是把 Java 对象转成 JSON 字符串,
2.3.6查询Redis缓存
引入模块+初始化 -> 建立连接 -> 执行能力 -> 归还连接,放入连接池
这段代码演示了如何在 OpenResty 环境下通过 Lua 脚本操作 Redis,主要涵盖了模块的引入初始化以及连接池的封装。
OpenResty提供了操作Redis的模块,我们只要引入该模块就能直接使用:
-
引入Redis模块,并初始化Redis对象:(在common.lua文件)
lua-- 引入redis模块 local redis = require("resty.redis") -- 初始化Redis对象 local red = redis:new() -- 设置Redis超时时间 red:set_timeouts(1000, 1000, 1000) -
封装函数,用来释放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 -
封装函数,从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同样将方法导出


2.3.7Nginx本地缓存
开启共享字典 -> 获取本地缓存对象-> 存储set->读取get
OpenResty为Nginx提供了shard dict的功能,可以在nginx的多个worker之间共享数据,实现缓存功能。
-
开启共享字典,在nginx.conf的http下添加配置:
nginx# 共享字典,也就是本地缓存,名称叫做:item_cache,大小150m lua_shared_dict item_cache 150m; -
操作共享字典:
lua-- 获取本地缓存对象 local item_cache = ngx.shared.item_cache -- 存储,指定key、value、过期时间,单位s,默认为0代表永不过期 item_cache:set('key', 'value', 1000) -- 读取 local val = item_cache:get('key')


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


2.4.2安装Canal
初识Canal:
译为水道/管道,是阿里巴巴旗下的一款开源项目,基于java开发。基于数据库增量日志解析,提供增量数据订阅和消费


1.开启MySQL主从
-
开启binlog
修改文件:
shvi /tmp/mysql/conf/my.cnf添加内容:
inilog-bin=/var/lib/mysql/mysql-bin binlog-do-db=heima配置解读:
log-bin=/var/lib/mysql/mysql-bin:设置binary log文件的存放地址和文件名,叫做mysql-binbinlog-do-db=heima:指定对哪个database记录binary log events,这里记录heima这个库
-
设置用户权限
添加一个仅用于数据同步的账户,出于安全考虑,这里仅提供对heima这个库的操作权限。
mysqlcreate user canal@'%' IDENTIFIED by 'canal'; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%' identified by 'canal'; FLUSH PRIVILEGES;重启mysql容器即可
docker restart mysql
2.安装Canal
-
创建网络
创建一个网络,将MySQL、Canal、MQ放到同一个Docker网络中:
shdocker network create heima让mysql加入这个网络:
shdocker network connect heima mysql -
安装Canal
上传到虚拟机,然后通过命令导入:
docker load -i canal.tar然后运行命令创建Canal容器:
shdocker 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=heima\\..* \ --network heima \ -d canal/canal-server:v1.1.5
2.4.3监听canal实现缓存同步
Canal客户端:

Canal提供了各种语言的客户端,当Canal监听到binlog变化时,会通知Canal的客户端。不过这里我们会使用GitHub上的第三方开源的canal-starter。地址:https://github.com/NormanGyllenhaal/canal-client
引入依赖:
xml
<!--canal-->
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
编写配置:
yaml
canal:
destination: heima # canal实例名称,要跟canal-server运行时设置的destination一致
server: 192.168.150.101:11111 # canal地址

策略一:只删除缓存
这是目前互联网大厂最主流的策略(如 Redis 官方推荐的 Cache-Aside 模式)。
核心逻辑
- 当数据库数据变更时,只删除 Redis 中对应的 Key。
- 下一次有请求来查询时,发现缓存没有(Miss),再去查数据库,并把结果重新写入缓存。
优点
- 简单:只需要知道 Key 就能删,不需要关心 Value 长什么样。
- 节省资源:如果数据改了但没人读,就不会浪费资源去更新缓存。
- 数据绝对准确:因为是查询时从数据库重新加载的,保证了缓存和数据库完全一致。
缺点
- 第一次查询变慢:删除后的第一次请求需要穿透到数据库,延迟较高。
- 数据库压力大:如果瞬间有大量请求发现缓存没了,会同时冲击数据库(缓存击穿)。
策略二:直接更新缓存
这是图中代码展示的策略,通常用于对性能要求极高的场景。
核心逻辑
- 当数据库数据变更时,利用 Canal 拿到最新的数据,直接把 Redis 中的旧值覆盖为新值。
优点
- 读性能极致:用户永远能直接读到缓存,不存在"第一次查询慢"的问题。
- 保护数据库:彻底避免了缓存击穿的风险,数据库压力最小。
缺点
- 代码复杂 :需要维护数据组装逻辑(如图中需要
Item对象)。- 资源浪费:如果数据更新了但没人读,这次更新就是白做的。
- 数据可能不一致:如果更新缓存失败了(比如 Redis 抖动),缓存里就会一直存着脏数据,且没有自动恢复机制(除非设置过期时间)

