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

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

先从 "传统缓存" 的坑说起 ------ 以前做缓存,基本是 "请求→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进程缓存(有就返回)→ 数据库(查完存到各级缓存)

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

相关推荐
Lee川8 小时前
深度拆解:基于面向对象思维的“就地编辑”组件全模块解析
javascript·架构
勤劳打代码8 小时前
Flutter 架构日记 — 状态管理
flutter·架构·前端框架
子兮曰14 小时前
后端字段又改了?我撸了一个 BFF 数据适配器,从此再也不怕接口“屎山”!
前端·javascript·架构
卓卓不是桌桌16 小时前
如何优雅地处理 iframe 跨域通信?这是我的开源方案
javascript·架构
Qlly16 小时前
DDD 架构为什么适合 MCP Server 开发?
人工智能·后端·架构
用户881586910911 天前
AI Agent 协作系统架构设计与实践
架构
鹏北海2 天前
Qiankun 微前端实战踩坑历程
前端·架构
货拉拉技术2 天前
货拉拉海豚平台-大模型推理加速工程化实践
人工智能·后端·架构
RoyLin2 天前
libkrun 深度解析:架构设计、模块实现与 Windows WHPX 后端
架构
CoovallyAIHub2 天前
实时视觉AI智能体框架来了!Vision Agents 狂揽7K Star,延迟低至30ms,YOLO+Gemini实时联动!
算法·架构·github