微服务多级缓存:从问题到实战(小白也能看懂的亿级流量方案)

一、先搞懂:为啥要搞 "多级缓存"?

先从 "传统缓存" 的坑说起 ------ 以前做缓存,基本是 "请求→Tomcat→查 Redis→没命中就查数据库"。但这方案扛不住大流量,问题很明显:

  1. Tomcat 成瓶颈:所有请求都要经过 Tomcat 处理,Tomcat 的性能有限,一旦流量大了就卡壳;
  2. 数据库遭 "暴击" :要是 Redis 缓存过期 / 没命中,所有请求会直接冲去查数据库,数据库很容易崩。
    而 "多级缓存" 的思路很简单:在请求经过的每一步都加缓存,能在前面拦住的请求,就别往后传。既减轻 Tomcat 压力,又保护数据库,还能提速。

二、多级缓存有哪些 "层级"?

核心是 5 个层级,从用户端到数据库依次拦截,咱们按 "从近到远" 排:

  1. 浏览器缓存:用户电脑里存一份(比如图片、静态数据),下次打开页面直接用,不用问服务器要;
  2. Nginx 缓存:请求到了服务器入口(Nginx),直接从 Nginx 拿数据,不用传给后面的服务;
  3. Redis 缓存:分布式缓存,多台服务能共享数据,比查数据库快;
  4. JVM 进程缓存:服务自己内存里的缓存(比如 Tomcat 的内存),拿数据不用走网络,最快;
  5. 数据库:最后兜底的,实在没缓存才查。

注意 :用作缓存的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 种方式:

  1. 按容量删:比如最多存 10000 条,超了删最久不用的;
  2. 按时间删:比如数据存 10 秒就过期(适合时效性强的 data);
  3. 按引用删:靠 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 为例):

  1. 下载安装包
  2. 解压后执行 ./configure;
  3. 执行 make && make install;
  4. 启动:/usr/local/openresty/nginx/sbin/nginx。
    OpenResty底层是基于Nginx的,查看OPenResty目录的nginx目录,结构与windows中安装的nginx基本一致:

第二步:配置 Nginx(让它能跑 Lua 脚本)

修改 OpenResty 的 Nginx 配置文件(路径:/usr/local/openresty/nginx/conf/nginx.conf):

  1. 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;    
    # 其他配置...
}
  1. 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脚本处理
    }
}
  1. 第三步:写 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

  1. 先开启 MySQL 的 binlog(Canal 靠 binlog 监听变化);
  2. 下载 [Canal];
  3. 配置 Canal,指定要监听的 MySQL 地址和数据库;
  4. 启动 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进程缓存(有就返回)→ 数据库(查完存到各级缓存)

核心思想:能在前面拦的,绝不往后传,用多级缓存层层拦截,扛住亿级流量。

相关推荐
叫我阿柒啊7 小时前
Java全栈工程师的面试实战:从基础到复杂问题的完整解析
java·数据库·spring boot·微服务·vue3·测试·全栈开发
聚客AI7 小时前
💥下一代推理引擎:vLLM如何重塑AI服务架构?
人工智能·架构·llm
半桶水专家7 小时前
Kafka 架构详解
分布式·架构·kafka
shinelord明7 小时前
【大数据技术实战】Flink+DS+Dinky 自动化构建数仓平台
大数据·运维·分布式·架构·flink·自动化
xzl048 小时前
pip的缓存
缓存·pip
gaoliheng0068 小时前
应用开发使用缓存
缓存
云雾J视界10 小时前
百万级并发下的微服务架构设计之道:从阿里双11看分布式系统核心原则与落地实践
分布式·微服务·云原生·架构
曾经的三心草10 小时前
微服务的编程测评系统20-虚拟机-nginx-部署
数据库·nginx·微服务