目录

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

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

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

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

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

二、多级缓存有哪些 “层级”?

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

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

注意:用作缓存的Nginx是业务Nginx,需要部署为集群,再有专门的Nginx用来做反向代理:
https://i-blog.csdnimg.cn/direct/2ed97916619e4ea7a9e9174ff3fcdcd3.png

三、逐个拆解:每个层级怎么实现?

1. 最 “近” 的缓存:JVM 进程缓存(服务自己的内存)

啥是进程缓存?
就是把数据存在服务的内存里(比如 Tomcat 的内存),查数据不用走网络(不用连 Redis / 数据库),速度最快。但缺点也明显:只能自己用(多台服务不共享)、存不下太多数据。

对比一下常用的两种缓存,小白一看就懂:

缓存类型优点缺点适合场景
分布式缓存(Redis)存得多、多服务共享、靠谱查数据要走网络,稍慢数据量大、要共享的场景
进程缓存(本地如HashMap、GuavaCache)查数据不用走网络,最快存得少、不共享、重启就没了数据量小、要极速查的场景

Caffeine

Caffeine是一个基于Java8开发的, 提供了近乎最佳命中率的高性能的本地缓存库。 是 Java 里最常用的进程缓存工具(Spring 内部也用它)。
Caffeine示例

@Test
void testBasicOps({
	//创建缓存对象
	Cache<StringString> 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 项目直接加)

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

第二步:配置缓存(告诉 Caffeine 存多少、怎么删旧数据)

写个配置类,定义两个缓存:一个存商品信息,一个存库存信息:

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 里用缓存
查数据时先看缓存,没有再查数据库,小白也能看懂的逻辑:

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条”:

Cache<String, String> cache = Caffeine.newBuilder()
        // 设置缓存大小上限为 1
        .maximumSize(1)
        .build();

tip:容量为1,只有最后设置的一个key可以保存下来

例子:设置 “数据存 10 秒过期”:

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基本一致:
    https://i-blog.csdnimg.cn/direct/f1b4fed4959c475292c1b8ba7252355d.png

第二步:配置 Nginx(让它能跑 Lua 脚本)
修改 OpenResty 的 Nginx 配置文件(路径:/usr/local/openresty/nginx/conf/nginx.conf):

  1. http节点下加 Lua 模块路径(让 Nginx 能找到 Lua 库):
    https://i-blog.csdnimg.cn/direct/ef4284621817423597a9dba24b96bf08.png
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 路径”(用户查商品走这个逻辑):
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脚本,核心代码如下:

-- 1. 导入需要的库:操作JSONRedis、本地缓存
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的keyitem: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 项目):


@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):

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异步通知
https://i-blog.csdnimg.cn/direct/5df06572ac4f4912b0ddf80dc971b260.png

实战:用 Canal 实现 “异步通知”

Canal 是阿里开源工具,能监听 MySQL 的变化(比如新增 / 修改 / 删除数据),然后通知服务更新缓存。
第一步:安装 Canal

  1. 先开启 MySQL 的 binlog(Canal 靠 binlog 监听变化);
  2. 下载 [Canal];
  3. 配置 Canal,指定要监听的 MySQL 地址和数据库;
  4. 启动 Canal。

第二步:Spring 项目监听 Canal 通知
1. 导入依赖:

<dependency>
    <groupId>top.javatool</groupId>
    <artifactId>canal-spring-boot-starter</artifactId>
    <version>1.2.1-RELEASE</version>
</dependency>

2. 写配置(application.yml):

canal:
  destination: heima  # Canal实例名(要和Canal服务配置一致
  server: 192.168.150.101:11111  # Canal服务地址

3. 写监听器(数据库变了就更新缓存):

// 监听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进程缓存(有就返回)→ 数据库(查完存到各级缓存)
https://i-blog.csdnimg.cn/direct/91f8118285aa41e4b40430735727b0b0.png