一、先搞懂:为啥要搞 "多级缓存"?
先从 "传统缓存" 的坑说起 ------ 以前做缓存,基本是 "请求→Tomcat→查 Redis→没命中就查数据库"。但这方案扛不住大流量,问题很明显:
- Tomcat 成瓶颈:所有请求都要经过 Tomcat 处理,Tomcat 的性能有限,一旦流量大了就卡壳;
- 数据库遭 "暴击" :要是 Redis 缓存过期 / 没命中,所有请求会直接冲去查数据库,数据库很容易崩。
而 "多级缓存" 的思路很简单:在请求经过的每一步都加缓存
,能在前面拦住的请求,就别往后传。既减轻 Tomcat 压力,又保护数据库,还能提速。
二、多级缓存有哪些 "层级"?
核心是 5 个层级,从用户端到数据库依次拦截,咱们按 "从近到远" 排:
- 浏览器缓存:用户电脑里存一份(比如图片、静态数据),下次打开页面直接用,不用问服务器要;
- Nginx 缓存:请求到了服务器入口(Nginx),直接从 Nginx 拿数据,不用传给后面的服务;
- Redis 缓存:分布式缓存,多台服务能共享数据,比查数据库快;
- JVM 进程缓存:服务自己内存里的缓存(比如 Tomcat 的内存),拿数据不用走网络,最快;
- 数据库:最后兜底的,实在没缓存才查。
注意 :用作缓存的Nginx是业务Nginx
,需要部署为集群,再有专门的Nginx用来做反向代理:
三、逐个拆解:每个层级怎么实现?
1. 最 "近" 的缓存:JVM 进程缓存(服务自己的内存)
啥是进程缓存?
就是把数据存在服务的内存里(比如 Tomcat 的内存),查数据不用走网络(不用连 Redis / 数据库),速度最快。但缺点也明显:只能自己用(多台服务不共享)、存不下太多数据。
对比一下常用的两种缓存,小白一看就懂:
缓存类型 | 优点 | 缺点 | 适合场景 |
---|---|---|---|
分布式缓存(Redis) | 存得多、多服务共享、靠谱 | 查数据要走网络,稍慢 | 数据量大、要共享的场景 |
进程缓存(本地如HashMap、GuavaCache) | 查数据不用走网络,最快 | 存得少、不共享、重启就没了 | 数据量小、要极速查的场景 |
Caffeine
Caffeine是一个基于Java8 开发的, 提供了近乎最佳命中率
的高性能的本地缓存库。Caffeine 是 Java 里最常用的进程缓存工具(Spring 内部也用它)。
Caffeine示例
dart
@Test
void testBasicOps(){
//创建缓存对象
Cache<String,String> cache =Caffeine.newBuilder().build();
//存数据
cache.put("food", "螺蛳粉");
//取数据,不存在则返回null
String food=cache.getIfPresent("gf");
System.out.println("food ="+food);
//取数据,不存在则去数据库查询
String defaultFood=cache.get("defaultFood", key->{
//这里可以去数据库根据key查询value
return "巧克力";
);
System.out.println("defaultGF="+defaultFood);
}
实战:用 Caffeine 实现进程缓存
简单 3 步搞定:
第一步:导入依赖(Spring 项目直接加)
dart
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
第二步:配置缓存(告诉 Caffeine 存多少、怎么删旧数据)
写个配置类
,定义两个缓存:一个存商品信息,一个存库存信息:
dart
import com.github.ben-manes.caffeine.cache.Cache; import com.github.ben-manes.caffeine.cache.Caffeine; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration;
@Configuration public class CaffeineConfig {
// 商品信息缓存:初始存100条,最多存10000条
@Bean
public Cache<Long, Item> itemCache() {
return Caffeine.newBuilder()
.initialCapacity(100) // 初始容量
.maximumSize(10000) // 最大容量(超了就删旧数据)
.build();
}
// 库存信息缓存:同上
@Bean
public Cache<Long, ItemStock> stockCache() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10000)
.build();
} }
第三步:在 Controller 里用缓存
查数据时先看缓存,没有再查数据库,小白也能看懂的逻辑:
dart
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.RestController;
import com.github.ben-manes.caffeine.cache.Cache;
@RestController
public class ItemController {
// 注入配置好的缓存
@Autowired
private Cache<Long, Item> itemCache;
@Autowired
private Cache<Long, ItemStock> stockCache;
// 查商品信息:先查缓存,没再查数据库
@GetMapping("/item/{id}")
public Item findById(@PathVariable Long id) {
// 缓存里有就直接拿,没有就执行后面的"查数据库"逻辑
return itemCache.get(id, key -> itemService.query()
.ne("status", 3) // 排除下架商品
.eq("id", key) // 按ID查
.one()); // 查一条
}
// 查库存:逻辑同上
@GetMapping("/item/stock/{id}")
public ItemStock findStockById(@PathVariable Long id) {
return stockCache.get(id, key -> stockService.getById(key));
}
}
Caffeine 的 "删旧数据" 策略
缓存满了 / 数据过期了,Caffeine 会自动删,常用 3 种方式:
- 按容量删:比如最多存 10000 条,超了删最久不用的;
- 按时间删:比如数据存 10 秒就过期(适合时效性强的 data);
- 按引用删:靠 Java 的 GC 回收(性能差,不用)。
例子:设置 "数据最多存1条":
dart
Cache<String, String> cache = Caffeine.newBuilder()
// 设置缓存大小上限为 1
.maximumSize(1)
.build();
tip
:容量为1,只有最后设置的一个key可以保存下来
例子:设置 "数据存 10 秒过期":
dart
Cache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(10)) // 从最后一次存数据开始算,10秒过期
.build();
2. 服务入口的缓存:Nginx 缓存(用 OpenResty)
Nginx 是服务的 "大门",但普通 Nginx 不能写逻辑,所以用OpenResty
(带 Lua 脚本的 Nginx,能自定义缓存逻辑)。
先搞懂:OpenResty 是啥?
简单说:OpenResty = Nginx + Lua 脚本
,能在 Nginx 里写代码,实现 "查本地缓存→查 Redis→查 Tomcat" 的逻辑,不用把请求传给 Tomcat。
实战:用 OpenResty 实现 Nginx 缓存
核心逻辑:用户请求来的时候,OpenResty 先查自己的本地缓存,没有就查 Redis,再没有就查 Tomcat,最后把数据存到缓存里。
第一步:安装 OpenResty
不用记命令,跟着步骤走(以 Linux 为例):
- 下载安装包
- 解压后执行 ./configure;
- 执行 make && make install;
- 启动:/usr/local/openresty/nginx/sbin/nginx。
OpenResty底层是基于Nginx的,查看OPenResty目录的nginx目录,结构与windows中安装的nginx基本一致:
第二步:配置 Nginx(让它能跑 Lua 脚本)
修改 OpenResty 的 Nginx 配置文件(路径:/usr/local/openresty/nginx/conf/nginx.conf):
- 在
http
节点下加 Lua 模块路径(让 Nginx 能找到 Lua 库):
dart
http {
# 加载Lua模块
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
lua_package_cpath "/usr/local/openresty/lualib/?.so;;";
# 开启Nginx本地缓存(给缓存起个名:item_cache,大小150M)
lua_shared_dict item_cache 150m;
# 其他配置...
}
- 在
server
节点下加 "监听/api/item
路径"(用户查商品走这个逻辑):
dart
server {
listen 8081; # 端口
server_name localhost;
# 用户请求/api/item/xxx时,用Lua脚本处理
location ~ /api/item/(\d+) { # 正则匹配:比如/api/item/1001,\d+就是商品ID
default_type application/json; # 返回JSON格式
content_by_lua_file lua/item.lua; # 用lua/item.lua脚本处理
}
}
- 第三步:写 Lua 脚本(实现缓存逻辑)
在 nginx 目录下建lua文件夹,新建item.lua
脚本,核心代码如下:
dart
-- 1. 导入需要的库:操作JSON、Redis、本地缓存
local cjson = require "cjson" -- 处理JSON
local redis = require "resty.redis" -- 操作Redis
local item_cache = ngx.shared.item_cache -- 获取Nginx本地缓存
-- 2. 封装:查Redis的函数
local function read_redis(ip, port, key)
local red = redis:new()
red:set_timeouts(1000, 1000, 1000) -- 超时时间
-- 连接Redis
local ok, err = red:connect(ip, port)
if not ok then
ngx.log(ngx.ERR, "连Redis失败:", err)
return nil
end
-- 查Redis
local val, err = red:get(key)
if not val or val == ngx.null then
ngx.log(ngx.ERR, "Redis没找到key:", key)
val = nil
end
-- 把Redis连接放回连接池(复用,不浪费)
red:set_keepalive(10000, 100)
return val
end
-- 3. 封装:查Tomcat的函数(用Nginx内部请求)
local function read_http(path)
-- 向Tomcat发请求(比如Tomcat地址是192.168.150.1:8081)
local resp = ngx.location.capture(path, {
method = ngx.HTTP_GET
})
if not resp or resp.status ~= 200 then
ngx.log(ngx.ERR, "查Tomcat失败:", path)
return nil
end
return resp.body
end
-- 4. 核心逻辑:先查本地缓存→再查Redis→最后查Tomcat
local function read_data(key, expire, path)
-- 第一步:查Nginx本地缓存
local val = item_cache:get(key)
if val then
return val -- 有缓存直接返回
end
-- 第二步:查Redis
val = read_redis("127.0.0.1", 6379, key)
if val then
item_cache:set(key, val, expire) -- 存到本地缓存
return val
end
-- 第三步:查Tomcat
val = read_http(path)
if val then
item_cache:set(key, val, expire) -- 存到本地缓存和Redis(这里省略Redis存逻辑)
return val
end
return nil -- 都没查到,返回空
end
-- 5. 处理请求:获取商品ID,调用上面的逻辑
local id = ngx.var[1] -- 从URL里拿商品ID(比如/api/item/1001,这里就是1001)
local item_key = "item:id:" .. id -- Redis的key:item:id:1001
local item_path = "/item/" .. id -- 查Tomcat的路径:/item/1001
-- 查商品信息:本地缓存存30分钟(1800秒),库存存1分钟(60秒)
local item_json = read_data(item_key, 1800, item_path)
ngx.say(item_json) -- 返回数据给用户
3. 分布式缓存:Redis(多服务共享数据)
Redis 是多级缓存里的 "中间层",多台服务能共享数据。关键要解决两个问题:
问题 1:服务刚启动,Redis 没数据怎么办?(缓存预热)
服务刚启动时,Redis 是空的(叫 "冷启动"),这时候请求会全冲去查数据库。解决办法是缓存预热
:服务启动时,主动把热点数据(比如所有商品)查出来存到 Redis。
实战代码(Spring 项目):
dart
@Component
public class RedisPreheat implements InitializingBean {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ItemService itemService;
// 服务启动后自动执行这个方法(InitializingBean的特性)
@Override
public void afterPropertiesSet() throws Exception {
// 1. 查所有商品
List<Item> itemList = itemService.list();
// 2. 存到Redis
for (Item item : itemList) {
String key = "item:id:" + item.getId();
String json = JSON.toJSONString(item);
redisTemplate.opsForValue().set(key, json);
}
}
}
问题 2:多台 Tomcat 的进程缓存不共享怎么办?(负载均衡 hash)
多台 Tomcat 的进程缓存是各自独立的,比如商品 1001 的缓存存在 Tomcat A,但请求被分到 Tomcat B,就会查不到缓存。解决办法:让同一商品的请求永远到同一台 Tomcat 。
在 Nginx 配置里改负载均衡规则(用hash $request_uri
):
dart
http {
# Tomcat集群:用请求URL的hash值分配,同一URL永远到同一台Tomcat
upstream tomcat-cluster {
hash $request_uri; # 关键:按请求路径hash
server 192.168.150.1:8081; # Tomcat 1
server 192.168.150.1:8082; # Tomcat 2
}
# 反向代理到Tomcat集群
location /item {
proxy_pass http://tomcat-cluster;
}
}
四、关键问题:缓存和数据库怎么同步?
缓存里的数据是从数据库来的,如果数据库数据改了(比如商品价格变了),缓存里的旧数据就会导致 "数据不一致"。常用 3 种同步方案:
同步方案 | 优点 | 缺点 | 适合场景 |
---|---|---|---|
设置有效期 | 简单,不用写额外代码 | 过期前数据可能不一致 | 数据更新慢(比如商品分类) |
同步双写 | 实时一致,改数据库时同步改缓存 | 代码耦合高(改数据库要顺带改缓存) | 数据更新快、要实时一致(比如订单) |
异步通知 | 低耦合,改数据库后发通知就行 | 有延迟(通知到缓存更新有时间差) | 数据更新快、能接受小延迟(比如商品库存) |
MQ异步通知 VS Canal异步通知
实战:用 Canal 实现 "异步通知"
Canal 是阿里开源工具,能监听 MySQL 的变化(比如新增 / 修改 / 删除数据),然后通知服务更新缓存。
第一步:安装 Canal
- 先开启 MySQL 的 binlog(Canal 靠 binlog 监听变化);
- 下载 [Canal];
- 配置 Canal,指定要监听的 MySQL 地址和数据库;
- 启动 Canal。
第二步:Spring 项目监听 Canal 通知
1. 导入依赖:
dart
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
2. 写配置(application.yml):
dart
canal:
destination: heima # Canal实例名(要和Canal服务配置一致)
server: 192.168.150.101:11111 # Canal服务地址
3. 写监听器(数据库变了就更新缓存):
dart
// 监听tb_item表(商品表)的变化
@CanalTable("tb_item")
@Component
public class ItemCanalHandler implements EntryHandler<Item> {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private Cache<Long, Item> itemCache;
// 数据库新增商品时执行
@Override
public void insert(Item item) {
// 更新JVM进程缓存
itemCache.put(item.getId(), item);
// 更新Redis
redisTemplate.opsForValue().set("item:id:" + item.getId(), JSON.toJSONString(item));
}
// 数据库修改商品时执行
@Override
public void update(Item oldItem, Item newItem) {
itemCache.put(newItem.getId(), newItem);
redisTemplate.opsForValue().set("item:id:" + newItem.getId(), JSON.toJSONString(newItem));
}
// 数据库删除商品时执行
@Override
public void delete(Item item) {
itemCache.invalidate(item.getId()); // 删进程缓存
redisTemplate.delete("item:id:" + item.getId()); // 删Redis
}
}
五、总结:多级缓存的完整流程
最后用一张图总结,小白也能记住:
用户请求 → 浏览器缓存(有就返回)→ Nginx本地缓存(有就返回)→ Redis(有就返回)→ JVM进程缓存(有就返回)→ 数据库(查完存到各级缓存)
核心思想:能在前面拦的,绝不往后传,用多级缓存层层拦截,扛住亿级流量。