微服務(wù)+多級(jí)緩存,性能起飛!
兄弟們,上周四半夜三點(diǎn),朋友發(fā)來(lái)消息:“哥救急!”,后面跟著一串截圖:線上微服務(wù)接口超時(shí)率飆到 30%,數(shù)據(jù)庫(kù) CPU 干到 100%,運(yùn)維小哥已經(jīng)在群里 @他八百次了。
我讓他先把接口監(jiān)控發(fā)過(guò)來(lái),一眼就看出來(lái)問(wèn)題:商品列表接口沒(méi)加緩存,用戶一點(diǎn)開(kāi) APP,所有請(qǐng)求直接扎進(jìn)數(shù)據(jù)庫(kù),就像春運(yùn)的時(shí)候所有人都擠一個(gè)檢票口,不堵才怪。后來(lái)給他加了個(gè)多級(jí)緩存,半小時(shí)不到,接口響應(yīng)時(shí)間從 500ms 降到 20ms,數(shù)據(jù)庫(kù) CPU 直接掉到 10% 以下。 這就是今天要跟大家聊的 “微服務(wù) + 多級(jí)緩存”,不是什么高深黑科技,但用好了是真能救命。
一、先吐槽下:?jiǎn)渭?jí)緩存就是 “瘸腿走路”
很多同學(xué)做微服務(wù),一提緩存就只想到 Redis—— 不是說(shuō) Redis 不好,而是單靠它,就像穿一只鞋跑步,跑不遠(yuǎn)還容易摔。咱們先掰扯下常見(jiàn)的 “單級(jí)緩存誤區(qū)”,看看你是不是也踩過(guò)。
1. 只靠本地緩存:像家里冰箱只能自己用
有些同學(xué)圖省事,在服務(wù)里用個(gè) HashMap 當(dāng)本地緩存(更講究點(diǎn)的用 Guava Cache),確實(shí)快 —— 畢竟是內(nèi)存操作,比查數(shù)據(jù)庫(kù)快 100 倍都不止。但問(wèn)題來(lái)了:微服務(wù)不是單臺(tái)機(jī)器跑??!
你部署 10 臺(tái)服務(wù)實(shí)例,每臺(tái)機(jī)器的本地緩存都是 “獨(dú)立王國(guó)”。比如商品價(jià)格改了,你只更了其中一臺(tái)的緩存,剩下 9 臺(tái)還存著舊價(jià)格,用戶刷到的價(jià)格一會(huì)兒高一會(huì)兒低,客服電話能被打爆。更坑的是,如果某臺(tái)機(jī)器緩存里的熱點(diǎn)數(shù)據(jù)過(guò)期了,所有請(qǐng)求會(huì)突然全扎進(jìn)這臺(tái)機(jī)器的數(shù)據(jù)庫(kù),直接把它干崩(這叫 “緩存擊穿” 的局部版)。
簡(jiǎn)單說(shuō):本地緩存是 “自家冰箱”,只能自己用,鄰居(其他實(shí)例)用不上,還容易藏 “過(guò)期食物”(舊數(shù)據(jù))。
2. 只靠 Redis:網(wǎng)絡(luò)是個(gè) “隱形殺手”
更多同學(xué)會(huì)選 Redis 當(dāng)分布式緩存,畢竟能跨實(shí)例共享數(shù)據(jù),還能抗高并發(fā)。但你有沒(méi)有算過(guò)一筆賬:Redis 再快,也是 “遠(yuǎn)程調(diào)用”—— 從服務(wù)實(shí)例發(fā)請(qǐng)求到 Redis,再等 Redis 返回,這中間的網(wǎng)絡(luò)開(kāi)銷可不小。
舉個(gè)真實(shí)例子:之前幫一個(gè)電商項(xiàng)目調(diào)優(yōu),商品詳情接口用了 Redis 緩存,響應(yīng)時(shí)間大概 80ms。后來(lái)加了本地緩存(Caffeine),同樣的接口直接降到 15ms—— 差了 5 倍多!為啥?因?yàn)楸镜鼐彺娌挥米呔W(wǎng)絡(luò),直接讀內(nèi)存,就像你從口袋里掏手機(jī),比從快遞站取快遞快多了。
更要命的是 Redis 也會(huì) “累”。比如秒殺活動(dòng),每秒幾萬(wàn)請(qǐng)求打過(guò)來(lái),就算 Redis 能扛住,網(wǎng)絡(luò)帶寬也可能被占滿,后面正常請(qǐng)求全卡住。這時(shí)候要是再遇到緩存雪崩(大量 key 同時(shí)過(guò)期),所有請(qǐng)求一起沖去數(shù)據(jù)庫(kù),那場(chǎng)面,數(shù)據(jù)庫(kù)直接 “原地去世”。
3. 結(jié)論:多級(jí)緩存是 “組合拳”,不是 “單選題”
單級(jí)緩存的問(wèn)題本質(zhì)是:本地緩存缺 “共享”,Redis 缺 “速度”。那解決辦法就很簡(jiǎn)單了 —— 把兩者結(jié)合起來(lái),再加上網(wǎng)關(guān)層的緩存(比如 Nginx),搞個(gè) “多級(jí)緩存”,讓請(qǐng)求像走 “過(guò)濾網(wǎng)” 一樣,一層一層被擋住,最后漏到數(shù)據(jù)庫(kù)的請(qǐng)求就沒(méi)幾個(gè)了。
就像小區(qū)安保:先看大門(mén)(網(wǎng)關(guān)緩存),不是小區(qū)的直接攔;進(jìn)了大門(mén)看單元門(mén)(本地緩存),住戶直接進(jìn);單元門(mén)沒(méi)卡,再查物業(yè)登記(Redis);最后實(shí)在不行才找業(yè)主確認(rèn)(數(shù)據(jù)庫(kù))—— 這樣效率才高,還不容易出亂子。
二、多級(jí)緩存怎么搭?從 “三層架構(gòu)” 講透
咱們聊最實(shí)用的 “三級(jí)緩存架構(gòu)”:網(wǎng)關(guān)緩存(Nginx)→ 本地緩存(Caffeine)→ 分布式緩存(Redis)。不是說(shuō)必須三層都上,小項(xiàng)目可能本地 + Redis 就夠了,大項(xiàng)目再補(bǔ)個(gè)網(wǎng)關(guān)緩存,按需搭配。
先給個(gè)整體流程圖,后面逐個(gè)拆解:
用戶請(qǐng)求 → Nginx網(wǎng)關(guān)(查網(wǎng)關(guān)緩存)→ 有就返回
                          ↓ 沒(méi)有
微服務(wù)實(shí)例(查本地緩存Caffeine)→ 有就返回
                          ↓ 沒(méi)有
Redis分布式緩存 → 有就返回(同時(shí)回寫(xiě)本地緩存)
                          ↓ 沒(méi)有
數(shù)據(jù)庫(kù) → 查詢結(jié)果(同時(shí)回寫(xiě)Redis和本地緩存)→ 返回1. 第一層:網(wǎng)關(guān)緩存(Nginx)——“大門(mén)衛(wèi)”,攔高頻靜態(tài)請(qǐng)求
網(wǎng)關(guān)是請(qǐng)求進(jìn)入微服務(wù)的第一站,用 Nginx 做緩存,主要攔那些 “不怎么變” 的靜態(tài)請(qǐng)求,比如商品分類列表、首頁(yè) Banner 圖這些。
為啥用 Nginx?因?yàn)樗?Java 服務(wù)輕量,抗并發(fā)能力更強(qiáng) ——Java 服務(wù)單機(jī)撐幾千 QPS 就不錯(cuò)了,Nginx 輕松上萬(wàn)。而且請(qǐng)求不用進(jìn) Java 服務(wù),直接在 Nginx 層面返回,效率高到飛起。
實(shí)戰(zhàn)配置:Nginx 緩存靜態(tài)接口
比如要緩存 “/api/v1/category/list” 這個(gè)分類接口,Nginx 配置大概長(zhǎng)這樣:
http {
    # 定義緩存區(qū):名字叫micro_cache,內(nèi)存100M,過(guò)期時(shí)間10分鐘
    proxy_cache_path /var/nginx/cache levels=1:2 keys_zone=micro_cache:100m inactive=10m max_size=1g;
    server {
        listen 80;
        server_name api.yourecommerce.com;
        location /api/v1/category/list {
            # 啟用緩存,用上面定義的micro_cache
            proxy_cache micro_cache;
            # 緩存key:用請(qǐng)求URI+參數(shù),避免不同請(qǐng)求混了
            proxy_cache_key $uri$is_args$args;
            # 200和304狀態(tài)碼緩存10分鐘
            proxy_cache_valid 200 304 10m;
            # 緩存命中率這些信息,加在響應(yīng)頭里,方便監(jiān)控
            add_header X-Cache-Status $upstream_cache_status;
            # 轉(zhuǎn)發(fā)到微服務(wù)集群
            proxy_pass http://micro_service_cluster;
        }
    }
}注意點(diǎn):別瞎緩存動(dòng)態(tài)接口
Nginx 緩存適合 “純靜態(tài)、少變化” 的接口,比如分類、Banner。像用戶訂單、購(gòu)物車這種 “每個(gè)人都不一樣” 的動(dòng)態(tài)接口,千萬(wàn)別用 Nginx 緩存 —— 不然張三能看到李四的訂單,那就等著背鍋吧。
如果非要緩存動(dòng)態(tài)接口,得在 key 里加用戶標(biāo)識(shí),比如proxy_cache_key $uri$is_args$args$cookie_user_id;,但這樣緩存命中率會(huì)很低,不如不用,所以一般不推薦。
2. 第二層:本地緩存(Caffeine)——“貼身管家”,快到離譜
本地緩存是微服務(wù)實(shí)例自己的 “內(nèi)存緩存”,用 Caffeine(Guava Cache 的升級(jí)版)最合適 —— 性能比 Guava 好,配置還靈活,現(xiàn)在 Java 項(xiàng)目基本都用它。
它的核心優(yōu)勢(shì)就一個(gè)字:快!不用走網(wǎng)絡(luò),直接讀 JVM 內(nèi)存,響應(yīng)時(shí)間能做到毫秒級(jí)甚至微秒級(jí)。適合存那些 “高頻訪問(wèn)、短期不變” 的數(shù)據(jù),比如商品詳情、熱門(mén)商品列表。
第一步:Spring Boot 集成 Caffeine(直接抄代碼)
先加依賴(Maven):
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version> <!-- 用最新版就行 -->
</dependency>然后寫(xiě)個(gè)配置類,定義緩存規(guī)則:
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
@Configuration
@EnableCaching // 開(kāi)啟緩存注解支持
public class CaffeineConfig {
    // 定義緩存管理器:不同緩存用不同規(guī)則
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        
        // 1. 商品詳情緩存:最多存1000個(gè),10分鐘過(guò)期
        Caffeine<Object, Object> productCache = Caffeine.newBuilder()
                .maximumSize(1000) // 最大緩存數(shù)量(滿了會(huì)刪最少用的)
                .expireAfterWrite(10, TimeUnit.MINUTES) // 寫(xiě)入后10分鐘過(guò)期
                .recordStats(); // 記錄緩存命中率(方便監(jiān)控)
        // 2. 熱門(mén)商品緩存:最多存500個(gè),5分鐘過(guò)期(更新更頻繁)
        Caffeine<Object, Object> hotProductCache = Caffeine.newBuilder()
                .maximumSize(500)
                .expireAfterWrite(5, TimeUnit.MINUTES)
                .recordStats();
        // 把不同緩存規(guī)則加進(jìn)去,用名字區(qū)分
        cacheManager.setCaffeineMap(Map.of(
                "productDetailCache", productCache,
                "hotProductCache", hotProductCache
        ));
        return cacheManager;
    }
}然后在 Service 層用注解就能用:
@Service
public class ProductService {
    @Autowired
    private ProductMapper productMapper;
    // 查商品詳情:用productDetailCache緩存
    @Cacheable(value = "productDetailCache", key = "#productId")
    public ProductVO getProductDetail(Long productId) {
        // 這里查數(shù)據(jù)庫(kù),沒(méi)緩存時(shí)才會(huì)執(zhí)行
        ProductDO productDO = productMapper.selectById(productId);
        // 轉(zhuǎn)成VO返回
        return convertToVO(productDO);
    }
    // 查熱門(mén)商品:用hotProductCache緩存
    @Cacheable(value = "hotProductCache", key = "'hotList'") // 固定key,因?yàn)槭橇斜?    public List<ProductVO> getHotProductList() {
        return productMapper.selectHotProductList();
    }
}第二步:Caffeine 核心配置解讀(別瞎配)
很多同學(xué)用 Caffeine 只知道設(shè)過(guò)期時(shí)間,其實(shí)幾個(gè)關(guān)鍵參數(shù)能決定緩存效果:
- maximumSize:最大緩存數(shù)量,必須設(shè)!不然緩存會(huì)無(wú)限膨脹,最后 JVM 內(nèi)存爆了,運(yùn)維小哥會(huì)提著刀找你。
 - expireAfterWrite:寫(xiě)入后過(guò)期時(shí)間,適合數(shù)據(jù)會(huì)變的場(chǎng)景(比如商品價(jià)格)。
 - expireAfterAccess:訪問(wèn)后過(guò)期時(shí)間,適合 “不用就過(guò)期” 的場(chǎng)景(比如用戶會(huì)話)。
 - recordStats:記錄命中率,一定要開(kāi)!后面監(jiān)控用,命中率低于 80% 就得調(diào)緩存策略了。
 
第三步:Caffeine 底層為啥快?(稍微深入點(diǎn))
Caffeine 用的是 “W-TinyLFU” 算法,比傳統(tǒng)的 LRU(最近最少使用)聰明多了。舉個(gè)例子:
LRU 就像你整理衣柜,只把最久沒(méi)穿的衣服扔掉。但有時(shí)候你剛買的新衣服(最近用了一次),因?yàn)橹皼](méi)穿過(guò),會(huì)被 LRU 當(dāng)成 “久沒(méi)穿” 的扔掉 —— 這就很蠢。
W-TinyLFU 會(huì) “記仇”:它會(huì)統(tǒng)計(jì)每個(gè) key 的訪問(wèn)次數(shù),哪怕是新 key,只要最近訪問(wèn)過(guò),就不會(huì)輕易扔掉。簡(jiǎn)單說(shuō),它既照顧 “最近用的”,又照顧 “經(jīng)常用的”,緩存命中率比 LRU 高不少。
3. 第三層:分布式緩存(Redis)——“共享倉(cāng)庫(kù)”,跨實(shí)例通用
Redis 是多級(jí)緩存的 “中堅(jiān)力量”,主要解決本地緩存 “不共享” 的問(wèn)題。比如你有 10 臺(tái)服務(wù)實(shí)例,本地緩存各存各的,Redis 就是那個(gè) “共享倉(cāng)庫(kù)”,讓所有實(shí)例都能拿到最新數(shù)據(jù)。
這部分重點(diǎn)不是教你怎么用 Redis(畢竟大家基本都會(huì)),而是講怎么避坑 —— 緩存穿透、擊穿、雪崩這三大難題,必須解決,不然 Redis 加了也白加。
第一步:Spring Boot 集成 Redis(基礎(chǔ)操作)
先加依賴:
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 用Redisson,功能更多,比如分布式鎖 -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.5</version>
</dependency>配置文件(application.yml):
spring:
  redis:
    host: 192.168.1.100
    port: 6379
    password: your_redis_password
    lettuce:
      pool:
        max-active: 8 # 最大連接數(shù)
        max-idle: 8 # 最大空閑連接
        min-idle: 2 # 最小空閑連接
    timeout: 2000ms # 超時(shí)時(shí)間
redisson:
  singleServerConfig:
    address: redis://192.168.1.100:6379
    password: your_redis_password
    connectionMinimumIdleSize: 2
    connectionPoolSize: 8然后用 RedisTemplate 或者 @Cacheable(和 Caffeine 類似),這里推薦用 Redisson,因?yàn)樗詭Х植际芥i、布隆過(guò)濾器這些工具,后面解決問(wèn)題要用。
第二步:三大難題解決方案(實(shí)戰(zhàn)版)
這部分是重點(diǎn),咱們一個(gè)個(gè)來(lái),每個(gè)問(wèn)題都給 “能直接用的代碼”。
1. 緩存穿透:故意查不存在的數(shù)據(jù),繞開(kāi)緩存打數(shù)據(jù)庫(kù)
比如有人故意查productId=-1,這個(gè) ID 在數(shù)據(jù)庫(kù)里根本沒(méi)有,所以緩存里也沒(méi)有,每次請(qǐng)求都會(huì)扎進(jìn)數(shù)據(jù)庫(kù) —— 如果每秒幾千個(gè)這種請(qǐng)求,數(shù)據(jù)庫(kù)直接扛不住。
解決方案:布隆過(guò)濾器 + 緩存空值
- 布隆過(guò)濾器:像小區(qū)門(mén)禁,先判斷這個(gè) ID 在不在數(shù)據(jù)庫(kù)里,不在就直接返回,不用查緩存和數(shù)據(jù)庫(kù)。
 - 緩存空值:就算布隆過(guò)濾器漏了,查數(shù)據(jù)庫(kù)沒(méi)找到,也往緩存里存?zhèn)€ “空值”(比如null),過(guò)期時(shí)間設(shè)短點(diǎn)(比如 1 分鐘),避免下次再查。
 
代碼實(shí)現(xiàn)(用 Redisson 布隆過(guò)濾器):
先初始化布隆過(guò)濾器(項(xiàng)目啟動(dòng)時(shí)執(zhí)行):
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
@Component
public class BloomFilterInit implements CommandLineRunner {
    @Resource
    private RedissonClient redissonClient;
    @Resource
    private ProductMapper productMapper;
    // 布隆過(guò)濾器名字
    private static final String PRODUCT_BLOOM_FILTER = "productBloomFilter";
    // 預(yù)計(jì)數(shù)據(jù)量(比如100萬(wàn)商品)
    private static final long EXPECTED_INSERTIONS = 1000000;
    // 誤判率(越小越費(fèi)內(nèi)存,一般設(shè)0.01就行)
    private static final double FALSE_POSITIVE_RATE = 0.01;
    @Override
    public void run(String... args) throws Exception {
        // 獲取布隆過(guò)濾器
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(PRODUCT_BLOOM_FILTER);
        // 初始化:如果沒(méi)初始化過(guò)才執(zhí)行
        if (!bloomFilter.isExists()) {
            bloomFilter.tryInit(EXPECTED_INSERTIONS, FALSE_POSITIVE_RATE);
            // 把數(shù)據(jù)庫(kù)里所有商品ID加載到布隆過(guò)濾器
            List<Long> allProductIds = productMapper.selectAllProductIds();
            allProductIds.forEach(bloomFilter::add);
        }
    }
}然后在 Service 層用:
@Service
publicclass ProductService {
    @Resource
    private RedissonClient redissonClient;
    @Resource
    private ProductMapper productMapper;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    // 查商品詳情(防穿透版)
    public ProductVO getProductDetail(Long productId) {
        String cacheKey = "product:detail:" + productId;
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("productBloomFilter");
        // 1. 先過(guò)布隆過(guò)濾器:不在就直接返回
        if (!bloomFilter.contains(productId)) {
            returnnull; // 或者返回“商品不存在”
        }
        // 2. 查Redis緩存
        String cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
        if (cacheValue != null) {
            // 緩存有值:如果是空值,返回null;否則轉(zhuǎn)VO
            return"null".equals(cacheValue) ? null : JSON.parseObject(cacheValue, ProductVO.class);
        }
        // 3. 查數(shù)據(jù)庫(kù)
        ProductDO productDO = productMapper.selectById(productId);
        if (productDO == null) {
            // 數(shù)據(jù)庫(kù)沒(méi)找到,緩存空值,1分鐘過(guò)期
            stringRedisTemplate.opsForValue().set(cacheKey, "null", 1, TimeUnit.MINUTES);
            returnnull;
        }
        // 4. 數(shù)據(jù)庫(kù)找到,緩存真實(shí)數(shù)據(jù),30分鐘過(guò)期
        ProductVO productVO = convertToVO(productDO);
        stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(productVO), 30, TimeUnit.MINUTES);
        return productVO;
    }
}2. 緩存擊穿:熱點(diǎn)數(shù)據(jù)過(guò)期,所有請(qǐng)求打數(shù)據(jù)庫(kù)
比如某個(gè)熱門(mén)商品(比如秒殺商品)的緩存過(guò)期了,這時(shí)候每秒幾萬(wàn)請(qǐng)求過(guò)來(lái),都發(fā)現(xiàn)緩存沒(méi)了,一起沖去數(shù)據(jù)庫(kù)查 —— 數(shù)據(jù)庫(kù)直接被打崩。
解決方案:分布式互斥鎖 + 熱點(diǎn)數(shù)據(jù)永不過(guò)期
- 分布式互斥鎖:只有一個(gè)線程能去查數(shù)據(jù)庫(kù),其他線程等著,查到后更新緩存,其他線程再?gòu)木彺婺脭?shù)據(jù)。
 - 熱點(diǎn)數(shù)據(jù)永不過(guò)期:對(duì)特別熱門(mén)的數(shù)據(jù),不設(shè)過(guò)期時(shí)間,而是用定時(shí)任務(wù)主動(dòng)更新緩存(比如每 5 分鐘更一次)。
 
代碼實(shí)現(xiàn)(用 Redisson 分布式鎖):
public ProductVO getHotProductDetail(Long productId) {
    String cacheKey = "product:hot:detail:" + productId;
    String lockKey = "lock:product:hot:" + productId;
    // 1. 先查本地緩存(Caffeine):熱點(diǎn)數(shù)據(jù)優(yōu)先讀本地
    ProductVO localCache = caffeineCache.getIfPresent(cacheKey);
    if (localCache != null) {
        return localCache;
    }
    // 2. 查Redis緩存
    String redisValue = stringRedisTemplate.opsForValue().get(cacheKey);
    if (redisValue != null && !"null".equals(redisValue)) {
        ProductVO productVO = JSON.parseObject(redisValue, ProductVO.class);
        // 回寫(xiě)本地緩存
        caffeineCache.put(cacheKey, productVO);
        return productVO;
    }
    // 3. 加分布式鎖:只有一個(gè)線程能查數(shù)據(jù)庫(kù)
    RLock lock = redissonClient.getLock(lockKey);
    try {
        // 加鎖:30秒自動(dòng)釋放(避免死鎖),最多等5秒
        boolean locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
        if (locked) {
            // 4. 加鎖成功,查數(shù)據(jù)庫(kù)
            ProductDO productDO = productMapper.selectById(productId);
            if (productDO == null) {
                stringRedisTemplate.opsForValue().set(cacheKey, "null", 1, TimeUnit.MINUTES);
                returnnull;
            }
            // 5. 數(shù)據(jù)庫(kù)有數(shù)據(jù),更新Redis和本地緩存
            ProductVO productVO = convertToVO(productDO);
            // Redis不設(shè)過(guò)期時(shí)間(永不過(guò)期),靠定時(shí)任務(wù)更新
            stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(productVO));
            // 本地緩存設(shè)5分鐘過(guò)期
            caffeineCache.put(cacheKey, productVO, 5, TimeUnit.MINUTES);
            return productVO;
        } else {
            // 加鎖失敗,等100ms再重試(遞歸或循環(huán)都行)
            Thread.sleep(100);
            return getHotProductDetail(productId);
        }
    } catch (InterruptedException e) {
        log.error("獲取鎖異常", e);
        returnnull;
    } finally {
        // 釋放鎖:只有持有鎖的線程才能釋放
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
// 定時(shí)任務(wù)更新熱點(diǎn)商品緩存(每5分鐘執(zhí)行一次)
@Scheduled(fixedRate = 5 * 60 * 1000)
public void refreshHotProductCache() {
    List<Long> hotProductIds = productMapper.selectHotProductIds(); // 查熱門(mén)商品ID列表
    for (Long productId : hotProductIds) {
        String cacheKey = "product:hot:detail:" + productId;
        ProductDO productDO = productMapper.selectById(productId);
        if (productDO != null) {
            ProductVO productVO = convertToVO(productDO);
            // 更新Redis緩存
            stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(productVO));
            // 更新本地緩存
            caffeineCache.put(cacheKey, productVO, 5, TimeUnit.MINUTES);
        }
    }
}3. 緩存雪崩:大量 key 同時(shí)過(guò)期,請(qǐng)求全打數(shù)據(jù)庫(kù)
比如你給所有商品緩存都設(shè)了 “凌晨 3 點(diǎn)過(guò)期”,到點(diǎn)后所有商品緩存一起失效,請(qǐng)求全沖去數(shù)據(jù)庫(kù) —— 這就是雪崩,比擊穿更嚴(yán)重。
解決方案:過(guò)期時(shí)間隨機(jī) + Redis 集群 + 服務(wù)熔斷降級(jí)
- 過(guò)期時(shí)間隨機(jī):給每個(gè) key 的過(guò)期時(shí)間加個(gè)隨機(jī)值(比如 30 分鐘 ±5 分鐘),避免同時(shí)過(guò)期。
 - Redis 集群:別用單機(jī) Redis,搞個(gè)主從 + 哨兵或者 Redis Cluster,就算一臺(tái)崩了,其他的還能扛。
 - 服務(wù)熔斷降級(jí):用 Sentinel 或者 Resilience4j,當(dāng)數(shù)據(jù)庫(kù)壓力太大時(shí),直接返回緩存的舊數(shù)據(jù)或者提示 “服務(wù)繁忙”,別硬扛。
 
代碼實(shí)現(xiàn)(過(guò)期時(shí)間隨機(jī)):
// 給緩存加隨機(jī)過(guò)期時(shí)間:30分鐘±5分鐘(25-35分鐘)
int baseExpire = 30; // 基礎(chǔ)過(guò)期時(shí)間(分鐘)
int randomExpire = new Random().nextInt(10); // 0-10分鐘隨機(jī)值
int totalExpire = baseExpire - 5 + randomExpire; // 25-35分鐘
// 存Redis時(shí)用這個(gè)總過(guò)期時(shí)間
stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(productVO), totalExpire, TimeUnit.MINUTES);服務(wù)熔斷降級(jí)(用 Resilience4j):
加依賴:
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot2</artifactId>
    <version>2.1.0</version>
</dependency>配置文件:
resilience4j:
  circuitbreaker:
    instances:
      productDbCircuit: # 熔斷器名字
        slidingWindowSize: 100 # 滑動(dòng)窗口大小
        failureRateThreshold: 50 # 失敗率閾值:50%失敗就熔斷
        waitDurationInOpenState: 60000 # 熔斷后60秒嘗試恢復(fù)
        permittedNumberOfCallsInHalfOpenState: 10 # 半開(kāi)狀態(tài)允許10個(gè)請(qǐng)求測(cè)試
  fallback:
    instances:
      productDbCircuit:
        fallback-exception: # 哪些異常觸發(fā)降級(jí)
          - java.sql.SQLException
          - org.springframework.dao.DataAccessExceptionService 層用注解:
// 查數(shù)據(jù)庫(kù)時(shí)加熔斷降級(jí):失敗了就返回緩存的舊數(shù)據(jù)(如果有的話)
@CircuitBreaker(name = "productDbCircuit", fallbackMethod = "getProductDetailFallback")
private ProductDO getProductFromDb(Long productId) {
    return productMapper.selectById(productId);
}
// 降級(jí)方法:參數(shù)和返回值要和原方法一致
private ProductDO getProductDetailFallback(Long productId, Exception e) {
    log.error("查數(shù)據(jù)庫(kù)失敗,觸發(fā)降級(jí)", e);
    // 嘗試從Redis拿舊數(shù)據(jù)(就算過(guò)期了也拿)
    String cacheKey = "product:detail:" + productId;
    String redisValue = stringRedisTemplate.opsForValue().get(cacheKey);
    if (redisValue != null && !"null".equals(redisValue)) {
        ProductVO productVO = JSON.parseObject(redisValue, ProductVO.class);
        return convertToDO(productVO); // 轉(zhuǎn)成DO返回
    }
    // 沒(méi)舊數(shù)據(jù)就拋異常(或者返回默認(rèn)值)
    thrownew RuntimeException("服務(wù)繁忙,請(qǐng)稍后再試");
}三、多級(jí)緩存協(xié)同:怎么讓三級(jí)緩存 “配合默契”
光搭好每一級(jí)還不夠,得讓它們 “協(xié)同工作”—— 什么時(shí)候讀哪一級(jí),什么時(shí)候更哪一級(jí),不然會(huì)出現(xiàn) “數(shù)據(jù)不一致” 的問(wèn)題(比如數(shù)據(jù)庫(kù)改了,緩存還是舊的)。
1. 查詢流程:從外到內(nèi),層層過(guò)濾
前面給過(guò)流程圖,這里再細(xì)化下,加個(gè)實(shí)際例子(查商品詳情):
- 用戶請(qǐng)求/api/v1/product/123,先到 Nginx 網(wǎng)關(guān) ——Nginx 查自己的緩存,發(fā)現(xiàn)沒(méi)有(因?yàn)樯唐吩斍槭莿?dòng)態(tài)的,一般不存 Nginx),轉(zhuǎn)發(fā)到微服務(wù)。
 - 微服務(wù)實(shí)例收到請(qǐng)求,先查本地緩存 Caffeine(key=productDetailCache:123)—— 如果有,直接返回,全程 20ms 以內(nèi)。
 - 本地緩存沒(méi)有,查 Redis(key=product:detail:123)—— 如果有,返回給用戶,同時(shí)把數(shù)據(jù)回寫(xiě)到本地緩存(下次再查就快了)。
 - Redis 也沒(méi)有,查數(shù)據(jù)庫(kù) —— 查到后,先更 Redis,再更本地緩存,最后返回給用戶。
 
整個(gè)流程下來(lái),大部分請(qǐng)求會(huì)被本地緩存和 Redis 擋住,數(shù)據(jù)庫(kù)壓力很小。
2. 更新策略:兩種方案,按需選擇
當(dāng)數(shù)據(jù)更新時(shí)(比如商品價(jià)格改了),怎么同步緩存?主要兩種方案:
方案 1:失效模式(推薦)—— 更新數(shù)據(jù)庫(kù)后,刪除緩存
流程:更新數(shù)據(jù)庫(kù) → 刪除 Redis 緩存 → 發(fā)送消息通知所有微服務(wù)實(shí)例刪除本地緩存。
優(yōu)點(diǎn):安全,避免更新緩存失敗導(dǎo)致不一致。
缺點(diǎn):刪除緩存后,第一次請(qǐng)求會(huì)查數(shù)據(jù)庫(kù)(但有互斥鎖擋著,問(wèn)題不大)。
代碼實(shí)現(xiàn)(用 RabbitMQ 通知本地緩存):
更新商品價(jià)格的 Service:
@Service
publicclass ProductUpdateService {
    @Resource
    private ProductMapper productMapper;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private RabbitTemplate rabbitTemplate;
    @Transactional// 加事務(wù),確保數(shù)據(jù)庫(kù)更新成功才刪緩存
    public void updateProductPrice(Long productId, BigDecimal newPrice) {
        // 1. 更新數(shù)據(jù)庫(kù)
        ProductDO productDO = new ProductDO();
        productDO.setId(productId);
        productDO.setPrice(newPrice);
        productMapper.updateById(productDO);
        // 2. 刪除Redis緩存
        String redisKey = "product:detail:" + productId;
        stringRedisTemplate.delete(redisKey);
        // 3. 發(fā)送消息,通知所有實(shí)例刪除本地緩存
        rabbitTemplate.convertAndSend("cache-invalidate-exchange", "product.detail", productId);
    }
}微服務(wù)實(shí)例接收消息,刪除本地緩存:
@Component
publicclass CacheInvalidateConsumer {
    @Resource
    private CacheManager cacheManager;
    @RabbitListener(queues = "product-detail-cache-queue")
    public void handleCacheInvalidate(Long productId) {
        // 獲取本地緩存,刪除對(duì)應(yīng)的key
        Cache productCache = cacheManager.getCache("productDetailCache");
        if (productCache != null) {
            productCache.evict(productId);
        }
        log.info("刪除本地緩存:productDetailCache:{}", productId);
    }
}方案 2:更新模式 —— 更新數(shù)據(jù)庫(kù)后,直接更新緩存
流程:更新數(shù)據(jù)庫(kù) → 更新 Redis 緩存 → 通知更新本地緩存。
優(yōu)點(diǎn):更新后緩存是最新的,第一次請(qǐng)求不用查數(shù)據(jù)庫(kù)。
缺點(diǎn):有并發(fā)問(wèn)題,比如兩個(gè)線程同時(shí)更新,可能導(dǎo)致緩存存舊數(shù)據(jù)。
比如:線程 A 更新數(shù)據(jù)庫(kù)(價(jià)格 100)→ 線程 B 更新數(shù)據(jù)庫(kù)(價(jià)格 200)→ 線程 B 更新緩存(200)→ 線程 A 更新緩存(100)—— 最后緩存是 100,數(shù)據(jù)庫(kù)是 200,不一致了。
所以一般推薦用 “失效模式”,雖然第一次請(qǐng)求會(huì)查數(shù)據(jù)庫(kù),但更安全。
3. 數(shù)據(jù)一致性:終極解決方案(Canal)
如果你的項(xiàng)目對(duì)數(shù)據(jù)一致性要求特別高(比如金融場(chǎng)景),光靠刪除緩存還不夠 —— 比如更新數(shù)據(jù)庫(kù)成功了,但刪除 Redis 緩存失敗了,這時(shí)候緩存還是舊的。
這時(shí)候可以用Canal—— 它能監(jiān)聽(tīng) MySQL 的 binlog 日志,當(dāng)數(shù)據(jù)庫(kù)數(shù)據(jù)變化時(shí),自動(dòng)同步更新緩存。
簡(jiǎn)單流程:
- Canal 偽裝成 MySQL 的從庫(kù),監(jiān)聽(tīng) binlog 日志。
 - 當(dāng) product 表的數(shù)據(jù)更新時(shí),Canal 捕獲到這個(gè)變化。
 - Canal 發(fā)送消息給微服務(wù),微服務(wù)收到后,更新 Redis 和本地緩存。
 
這樣就算手動(dòng)刪除緩存失敗,Canal 也能兜底,確保緩存和數(shù)據(jù)庫(kù)一致。
四、實(shí)戰(zhàn)案例:從 “卡成狗” 到 “飛起來(lái)” 的優(yōu)化過(guò)程
最后給個(gè)真實(shí)案例,讓大家看看多級(jí)緩存的效果 —— 去年幫一個(gè)電商客戶做的商品詳情接口優(yōu)化,數(shù)據(jù)說(shuō)話最有說(shuō)服力。
優(yōu)化前:只有 Redis 緩存
- 接口響應(yīng)時(shí)間:80-120ms
 - QPS 峰值:5000(再高就超時(shí))
 - 數(shù)據(jù)庫(kù) CPU:高峰期 60%-70%
 - 問(wèn)題:秒殺活動(dòng)時(shí),Redis 扛不住,接口超時(shí)率 20%+
 
優(yōu)化后:Nginx+Caffeine+Redis 三級(jí)緩存
- 接口響應(yīng)時(shí)間:15-30ms(降了 70%+)
 - QPS 峰值:10 萬(wàn)(翻了 20 倍)
 - 數(shù)據(jù)庫(kù) CPU:高峰期 5%-10%(降了 90%)
 - 效果:秒殺活動(dòng)時(shí),接口零超時(shí),數(shù)據(jù)庫(kù)壓力幾乎可以忽略。
 
關(guān)鍵優(yōu)化點(diǎn):
- 首頁(yè) Banner 圖、分類列表這些靜態(tài)數(shù)據(jù),用 Nginx 緩存,QPS 直接扛住 5 萬(wàn)。
 - 熱門(mén)商品詳情用 Caffeine 本地緩存,90% 的請(qǐng)求直接在本地返回,不用查 Redis。
 - Redis 只存非熱門(mén)商品和兜底數(shù)據(jù),壓力大減,再配合集群,穩(wěn)如老狗。
 - 用 Canal 同步緩存,數(shù)據(jù)一致性沒(méi)問(wèn)題,客服再也沒(méi)收到 “價(jià)格不一致” 的投訴。
 
五、踩坑指南:這些坑我替你踩過(guò)了
- 本地緩存沒(méi)設(shè)最大數(shù)量:之前有個(gè)同學(xué)用 Caffeine 沒(méi)設(shè) maximumSize,上線后 JVM 內(nèi)存一天天漲,最后 OOM 崩潰 —— 記住,本地緩存一定要設(shè)最大數(shù)量!
 - Redis 緩存 key 沒(méi)加前綴:不同業(yè)務(wù)的 key 混在一起,比如product:123和order:123,后面清理緩存時(shí)容易刪錯(cuò) ——key 一定要加業(yè)務(wù)前綴,比如product:detail:123。
 - 分布式鎖沒(méi)設(shè)過(guò)期時(shí)間:加鎖后如果服務(wù)崩了,鎖沒(méi)釋放,其他線程永遠(yuǎn)拿不到鎖 —— 鎖一定要設(shè)自動(dòng)過(guò)期時(shí)間,比如 30 秒。
 - 緩存命中率沒(méi)監(jiān)控:不知道緩存效果怎么樣,瞎調(diào)參數(shù) —— 一定要監(jiān)控 Caffeine 和 Redis 的命中率,Caffeine 命中率低于 80% 就調(diào) maximumSize,Redis 低于 70% 就調(diào)過(guò)期時(shí)間。
 - 熱點(diǎn)數(shù)據(jù)沒(méi)單獨(dú)處理:把熱門(mén)商品和普通商品放一個(gè)緩存,熱門(mén)商品過(guò)期時(shí)導(dǎo)致雪崩 —— 熱門(mén)商品要單獨(dú)設(shè)緩存規(guī)則,用永不過(guò)期 + 定時(shí)更新。
 
六、總結(jié):多級(jí)緩存不是 “銀彈”,但真能救命
最后跟大家說(shuō)句實(shí)在的:多級(jí)緩存不是萬(wàn)能的,但在微服務(wù)性能優(yōu)化里,它是性價(jià)比最高的方案 —— 不用改太多代碼,就能讓性能翻好幾倍。
記住幾個(gè)核心原則:
- 分層過(guò)濾:網(wǎng)關(guān)攔靜態(tài),本地?cái)r高頻,Redis 攔中頻,數(shù)據(jù)庫(kù)扛低頻。
 - 按需選擇:小項(xiàng)目本地 + Redis 就夠了,大項(xiàng)目再補(bǔ)網(wǎng)關(guān)和 Canal。
 - 先穩(wěn)后快:先解決緩存穿透、擊穿、雪崩這些問(wèn)題,再追求性能極致。
 - 監(jiān)控為王:緩存命中率、Redis 內(nèi)存、數(shù)據(jù)庫(kù)壓力,這些指標(biāo)一定要監(jiān)控,不然出問(wèn)題都不知道在哪。
 
下次再遇到微服務(wù)性能不行,別光顧著加機(jī)器,先試試多級(jí)緩存,說(shuō)不定幾行代碼就能解決問(wèn)題,還能省不少服務(wù)器錢。















 
 
 














 
 
 
 