跳转到内容
123xiao | 无名键客

《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:从热点数据防穿透到一致性治理》

字数: 0 阅读时长: 1 分钟

Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:从热点数据防穿透到一致性治理

在 Spring Boot 项目里,Spring Cache + Redis 是很多团队的默认组合:上手快、注解简单、接入成本低。
但真正一上生产,问题往往不是“怎么把数据放进 Redis”,而是:

  • 热点数据突然被打爆,数据库扛不住
  • 缓存里有,应用本地也有,但两层不一致
  • 缓存击穿、穿透、雪崩一起来
  • 更新数据库后,缓存什么时候删、删几层、谁先删
  • 线上排查时发现:命中了缓存,但命中的不是你以为的那一层

这篇文章我不打算只讲注解怎么写,而是带你从一个可运行的 Spring Boot 示例出发,把“本地缓存 + Redis 二级缓存 + 数据库回源 + 一致性治理”的实战链路完整走一遍。


背景与问题

先看一个典型场景:商品详情页。

  • 某个商品是热点商品,QPS 很高
  • 读请求远大于写请求
  • 商品标题、价格、库存、上下架状态经常被读取
  • 运营偶尔会改价、改文案、上下架

如果你只有 Redis 这一层缓存,虽然能扛住大部分请求,但每次读取还是要走网络 IO。
当热点非常集中时,应用实例本地如果没有缓存能力,Redis 本身也会成为瓶颈点。

所以很多系统会做成多级缓存

  1. L1:应用内本地缓存,例如 Caffeine
  2. L2:分布式缓存,例如 Redis
  3. L3:数据库

读取时优先命中 L1,再查 L2,最后落 DB。
写入时更新 DB,并清理或刷新多级缓存。

但多级缓存的难点恰恰在于:快是快了,一致性怎么保证?


典型问题清单

1. 缓存穿透

查一个根本不存在的商品 ID,缓存没有,Redis 没有,数据库也没有。
如果有人恶意刷大量不存在的 ID,请求会直接穿透到数据库。

2. 缓存击穿

某个热点 Key 恰好过期,瞬间大量请求同时回源数据库。

3. 缓存雪崩

大量 Key 在同一时间失效,导致数据库压力陡增。

4. 多级缓存不一致

数据库更新了,Redis 删了,但本地缓存还留着旧值。
这个坑我自己就踩过:明明 Redis 已经是新数据,接口还是返回旧值,最后发现是应用本地缓存没失效。


前置知识与环境准备

本文示例基于以下技术栈:

  • JDK 17
  • Spring Boot 3.x
  • Spring Cache
  • Redis
  • Caffeine
  • Maven

依赖配置

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

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

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

核心原理

先用一张图把整体链路看清。

flowchart TD
    A[客户端请求商品详情] --> B{L1 本地缓存 Caffeine 命中?}
    B -- 是 --> C[直接返回]
    B -- 否 --> D{L2 Redis 命中?}
    D -- 是 --> E[写入 L1 后返回]
    D -- 否 --> F{获取互斥锁成功?}
    F -- 否 --> G[短暂等待后重试 Redis/L1]
    F -- 是 --> H[查询数据库]
    H --> I{商品存在?}
    I -- 否 --> J[写入空值缓存 防穿透]
    I -- 是 --> K[写入 Redis]
    K --> L[写入 L1]
    J --> C
    L --> C

这套机制里,有几个关键点:

1. L1 + L2 的职责划分

L1 本地缓存

优点:

  • 访问速度最快
  • 减少 Redis 网络开销
  • 热点 Key 命中率高时收益明显

缺点:

  • 天然是实例级别
  • 多节点之间不会自动同步
  • 更容易产生脏读

L2 Redis

优点:

  • 所有实例共享
  • 易于统一过期时间管理
  • 能支撑大部分分布式缓存需求

缺点:

  • 仍然有网络成本
  • 失效时会产生集中回源风险

2. Spring Cache 的角色

Spring Cache 更像一层缓存抽象,它帮你统一了:

  • @Cacheable:查缓存,没有则执行方法并写缓存
  • @CachePut:执行方法,并更新缓存
  • @CacheEvict:删除缓存

但要注意:

Spring Cache 解决的是“缓存接入便利性”,不是“复杂一致性策略自动化”。

比如多级缓存同步、热点 Key 互斥重建、空值缓存策略、广播清理,这些往往还需要你自己补上。

3. 一致性治理思路

在读多写少的场景,我更推荐这种策略:

  • 读路径:L1 -> L2 -> DB
  • 写路径:先更新 DB,再删除 L2,再删除 L1
  • 必要时延迟双删
  • 跨实例通过消息或订阅广播删除 L1

看起来是“删缓存”而不是“更新缓存”,原因很简单:

  • 更新缓存要考虑多个层级、多个节点
  • 一旦更新中间失败,状态会更复杂
  • 删除缓存更稳,后续由读请求自然重建

时序图:读取与更新

sequenceDiagram
    participant C as Client
    participant A as App
    participant L1 as Caffeine
    participant R as Redis
    participant DB as Database

    C->>A: GET /products/1
    A->>L1: get(1)
    alt L1 hit
        L1-->>A: 商品数据
        A-->>C: 返回
    else L1 miss
        A->>R: get(product:1)
        alt Redis hit
            R-->>A: 商品数据
            A->>L1: put(1,data)
            A-->>C: 返回
        else Redis miss
            A->>DB: select * from product where id=1
            DB-->>A: 商品数据/空
            A->>R: setex product:1
            A->>L1: put(1,data)
            A-->>C: 返回
        end
    end

    C->>A: PUT /products/1
    A->>DB: update product
    A->>R: delete product:1
    A->>L1: invalidate(1)
    A-->>C: 更新成功

实战代码(可运行)

下面给出一个最小可运行版本。
为了突出多级缓存核心逻辑,我这里用“内存 Map 模拟数据库”。你接到真实项目里,只需要把 Repository 换成 JPA/MyBatis 即可。


1. application.yml

server:
  port: 8080

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: none

这里把 Spring 默认 CacheManager 先关闭,原因是我们要自己实现一个更可控的多级缓存服务


2. 启动类

package com.example.multicache;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MultiCacheApplication {
    public static void main(String[] args) {
        SpringApplication.run(MultiCacheApplication.class, args);
    }
}

3. 实体类 Product

package com.example.multicache.model;

import java.io.Serializable;
import java.math.BigDecimal;

public class Product implements Serializable {
    private Long id;
    private String name;
    private BigDecimal price;
    private Integer stock;

    public Product() {
    }

    public Product(Long id, String name, BigDecimal price, Integer stock) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.stock = stock;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }

    public Integer getStock() {
        return stock;
    }

    public void setStock(Integer stock) {
        this.stock = stock;
    }
}

4. 模拟数据库 Repository

package com.example.multicache.repository;

import com.example.multicache.model.Product;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Repository
public class ProductRepository {

    private final Map<Long, Product> db = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        db.put(1L, new Product(1L, "机械键盘", new BigDecimal("299.00"), 100));
        db.put(2L, new Product(2L, "显示器", new BigDecimal("1299.00"), 50));
    }

    public Product findById(Long id) {
        sleep(100); // 模拟数据库查询耗时
        return db.get(id);
    }

    public Product update(Product product) {
        sleep(50); // 模拟数据库更新耗时
        db.put(product.getId(), product);
        return product;
    }

    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ignored) {
        }
    }
}

5. Redis 配置

package com.example.multicache.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.*;

@Configuration
public class RedisConfig {

    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        return new StringRedisTemplate(factory);
    }

    @Bean
    public org.springframework.data.redis.core.RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory factory) {

        org.springframework.data.redis.core.RedisTemplate<String, Object> template =
                new org.springframework.data.redis.core.RedisTemplate<>();
        template.setConnectionFactory(factory);

        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(
                mapper.getPolymorphicTypeValidator(),
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );

        Jackson2JsonRedisSerializer<Object> serializer =
                new Jackson2JsonRedisSerializer<>(mapper, Object.class);

        StringRedisSerializer stringSerializer = new StringRedisSerializer();

        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);
        template.afterPropertiesSet();
        return template;
    }
}

实际生产里,Jackson 默认类型信息配置要更谨慎,后面我会在“安全最佳实践”里再讲。


6. Caffeine 本地缓存配置

package com.example.multicache.config;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
public class LocalCacheConfig {

    @Bean
    public Cache<String, Object> localCache() {
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .expireAfterWrite(30, TimeUnit.SECONDS)
                .recordStats()
                .build();
    }
}

7. 多级缓存服务

这是整篇文章最关键的部分。

package com.example.multicache.service;

import com.example.multicache.model.Product;
import com.example.multicache.repository.ProductRepository;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Service
public class ProductService {

    private static final String PRODUCT_CACHE_KEY = "product:";
    private static final String LOCK_KEY = "lock:product:";
    private static final String NULL_VALUE = "NULL";

    private final Cache<String, Object> localCache;
    private final RedisTemplate<String, Object> redisTemplate;
    private final ProductRepository productRepository;

    public ProductService(Cache<String, Object> localCache,
                          RedisTemplate<String, Object> redisTemplate,
                          ProductRepository productRepository) {
        this.localCache = localCache;
        this.redisTemplate = redisTemplate;
        this.productRepository = productRepository;
    }

    public Product getProductById(Long id) {
        String cacheKey = PRODUCT_CACHE_KEY + id;

        // 1. 查本地缓存
        Object localValue = localCache.getIfPresent(cacheKey);
        if (localValue != null) {
            if (NULL_VALUE.equals(localValue)) {
                return null;
            }
            return (Product) localValue;
        }

        // 2. 查 Redis
        Object redisValue = redisTemplate.opsForValue().get(cacheKey);
        if (redisValue != null) {
            localCache.put(cacheKey, redisValue);
            if (NULL_VALUE.equals(redisValue)) {
                return null;
            }
            return (Product) redisValue;
        }

        // 3. 互斥锁,防止缓存击穿
        String lockKey = LOCK_KEY + id;
        String lockValue = UUID.randomUUID().toString();
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5));

        if (Boolean.TRUE.equals(locked)) {
            try {
                // 双检,防止拿到锁前别人已经构建好缓存
                Object again = redisTemplate.opsForValue().get(cacheKey);
                if (again != null) {
                    localCache.put(cacheKey, again);
                    if (NULL_VALUE.equals(again)) {
                        return null;
                    }
                    return (Product) again;
                }

                Product product = productRepository.findById(id);
                if (product == null) {
                    // 防穿透:缓存空值,过期时间要短
                    redisTemplate.opsForValue().set(cacheKey, NULL_VALUE, 60, TimeUnit.SECONDS);
                    localCache.put(cacheKey, NULL_VALUE);
                    return null;
                }

                // 防雪崩:TTL 可加随机值,这里简化为固定值
                redisTemplate.opsForValue().set(cacheKey, product, 10, TimeUnit.MINUTES);
                localCache.put(cacheKey, product);
                return product;
            } finally {
                Object currentLockValue = redisTemplate.opsForValue().get(lockKey);
                if (Objects.equals(lockValue, currentLockValue)) {
                    redisTemplate.delete(lockKey);
                }
            }
        }

        // 4. 没拿到锁,短暂等待后重试
        try {
            Thread.sleep(50);
        } catch (InterruptedException ignored) {
        }

        Object retry = redisTemplate.opsForValue().get(cacheKey);
        if (retry != null) {
            localCache.put(cacheKey, retry);
            if (NULL_VALUE.equals(retry)) {
                return null;
            }
            return (Product) retry;
        }

        // 最后兜底回源,避免极端情况下请求失败
        return productRepository.findById(id);
    }

    public Product updateProduct(Product product) {
        Product updated = productRepository.update(product);

        String cacheKey = PRODUCT_CACHE_KEY + product.getId();

        // 先删 Redis,再删本地缓存
        redisTemplate.delete(cacheKey);
        localCache.invalidate(cacheKey);

        // 可选:延迟双删
        new Thread(() -> {
            try {
                Thread.sleep(200);
                redisTemplate.delete(cacheKey);
                localCache.invalidate(cacheKey);
            } catch (InterruptedException ignored) {
            }
        }).start();

        return updated;
    }
}

8. Controller

package com.example.multicache.controller;

import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public Product getById(@PathVariable Long id) {
        return productService.getProductById(id);
    }

    @PutMapping("/{id}")
    public Product update(@PathVariable Long id, @RequestBody @Valid Product product) {
        product.setId(id);
        return productService.updateProduct(product);
    }
}

逐步验证清单

建议你按这个顺序验证,比较容易理解整个链路。

1. 首次查询,观察 DB 回源

请求:

curl http://localhost:8080/products/1

预期:

  • 本地缓存 miss
  • Redis miss
  • 查询 DB
  • 写 Redis
  • 写本地缓存

2. 再次查询,观察本地缓存命中

再次执行:

curl http://localhost:8080/products/1

预期:

  • 直接命中本地缓存
  • 接口响应明显更快

3. 查询不存在的 ID,验证空值缓存

curl http://localhost:8080/products/999

预期:

  • 首次查询落 DB,返回空
  • Redis 写入 NULL
  • 短时间内重复请求不再打 DB

4. 更新商品,验证缓存失效

curl -X PUT http://localhost:8080/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"机械键盘Pro","price":399.00,"stock":88}'

然后再次查询:

curl http://localhost:8080/products/1

预期:

  • 更新后缓存被删除
  • 再查时重新构建
  • 返回新值

用 Spring Cache 注解怎么接?

上面的代码更适合你理解原理。
如果你想在项目里继续保留 Spring Cache 的开发体验,可以把它用在单层缓存场景,或者作为某一层的统一抽象。

例如最基础的 Redis 缓存写法:

@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product findProduct(Long id) {
    return productRepository.findById(id);
}

@CacheEvict(cacheNames = "product", key = "#product.id")
public Product updateProduct(Product product) {
    return productRepository.update(product);
}

但要注意两件事:

  1. 多级缓存不是写几个 @Cacheable 就完事
  2. 跨实例 L1 失效同步,Spring Cache 默认不帮你处理

所以我的建议是:

  • 简单系统:先用 Spring Cache + Redis
  • 热点明显、性能敏感系统:核心链路手写多级缓存逻辑
  • 需要统一治理:对外暴露一层缓存组件,业务方只调组件接口

多级缓存一致性治理策略

这部分是实战里最容易“差一点点就出事故”的地方。

1. 为什么推荐“更新 DB,删除缓存”

先更新数据库,再删除缓存,是更稳妥的基本策略。

stateDiagram-v2
    [*] --> ReadOld
    ReadOld --> UpdateDB: 写请求到来
    UpdateDB --> DeleteRedis
    DeleteRedis --> DeleteLocal
    DeleteLocal --> RebuildByRead
    RebuildByRead --> [*]

如果你反过来先删缓存再更新 DB,会出现一个窗口期:

  • A 线程删了缓存
  • B 线程读请求进来,发现没有缓存,去 DB 读到旧值并回填缓存
  • A 线程这时才更新 DB
  • 缓存里就重新出现旧值了

这就是经典并发脏数据问题。

2. 延迟双删是否必须?

不是必须,但在以下场景有价值:

  • 并发读写频繁
  • 数据库主从延迟明显
  • 删除缓存后可能马上被旧读回填

常见做法:

  1. 更新 DB
  2. 删除缓存
  3. 休眠几十到几百毫秒
  4. 再删一次缓存

但要说实话,延迟双删不是银弹
如果你的核心诉求是强一致,那缓存本身就不适合作为权威数据源。

3. 多实例本地缓存怎么同步失效?

单机版好做,多机版才是真问题。

常见方案:

  • Redis Pub/Sub 广播失效消息
  • MQ 广播缓存删除事件
  • 统一版本号控制
  • 直接缩短 L1 TTL

我的经验是:

  • 读多写少:L1 TTL 设短一点,简单有效
  • 写较频繁:配合消息广播清理 L1
  • 强实时性要求高:慎用本地缓存,或者只缓存极稳定字段

常见坑与排查

这一节我尽量写得接地气一点,因为这些问题,真的是上线后最常见的。

1. @Cacheable 不生效

现象

方法明明加了 @Cacheable,但每次还是会执行。

原因

很多时候是同类内部调用导致的。
Spring Cache 基于 AOP 代理,如果你在同一个类里直接 this.xxx() 调用,代理绕过去了。

处理

把缓存方法拆到另一个 Bean,或者通过代理对象调用。


2. 本地缓存已经删了,还是读到旧数据

可能原因

  • 实际删的是 Redis,没删本地缓存
  • 多实例场景下只删了当前节点的本地缓存
  • 某个线程在删除后又把旧值回填了

排查建议

  • 打印缓存 key、节点实例标识、缓存层级命中日志
  • 明确区分:L1 hit / L2 hit / DB hit
  • 更新链路和回填链路都加 traceId

3. Redis 里出现奇怪的序列化内容

原因

JDK 序列化、Jackson 序列化、String 序列化混用。

建议

全项目统一 RedisTemplate 的序列化策略,不要一个地方存字符串,一个地方存对象,最后自己都看不懂。


4. 热点 Key 到期瞬间,数据库被打满

原因

TTL 统一、热点 key 过期同时回源。

处理

  • 热点 Key 永不过期,改为异步刷新
  • 加互斥锁
  • TTL 加随机值
  • 对超热点数据提前预热

5. 空值缓存导致“数据后来新增了但查不到”

现象

某个 ID 之前不存在,被缓存了 NULL,后来数据库插入了这条记录,但短时间内仍查不到。

处理

  • 空值缓存 TTL 设短
  • 新增数据时主动删除该 Key
  • 不要把空值 TTL 设得和正常数据一样长

安全/性能最佳实践

安全方面

1. 不要盲目开启 Jackson 默认类型

如果 Redis 数据来源不完全可信,activateDefaultTyping 要谨慎。
生产上更稳妥的做法是:

  • 指定具体类型序列化
  • 使用白名单类型
  • 对外部输入参与构造缓存对象时做好校验

2. 缓存 Key 不要拼接敏感信息

例如手机号、身份证号、token,尽量不要裸拼进 key。
必要时做 hash 处理。

3. 控制缓存穿透攻击面

  • 参数校验先拦截非法 ID
  • 对不存在对象缓存空值
  • 更严格场景可配合布隆过滤器

性能方面

1. TTL 加随机值,避免雪崩

例如:

  • 正常 TTL:10 分钟
  • 随机扩展:0~120 秒

这样可以打散失效时间。

2. 本地缓存容量不要瞎配

太小,命中率低;
太大,GC 压力和内存占用又会上来。

建议你基于这些指标观察:

  • hit rate
  • eviction count
  • average load penalty

3. 热点数据优先本地缓存

适合:

  • 商品详情
  • 配置字典
  • 用户权限快照
  • 只读元数据

不太适合:

  • 强一致库存
  • 高频变化余额
  • 强事务依赖状态

4. 删除缓存优先于更新缓存

尤其在多级缓存、多节点场景,删缓存更容易收敛问题。

5. 给缓存链路打指标

至少要有:

  • L1 命中率
  • L2 命中率
  • DB 回源次数
  • Key 重建耗时
  • 锁等待次数
  • 空值缓存命中次数

没有监控的多级缓存,线上基本靠猜。


方案边界与取舍

不是所有系统都应该上多级缓存。

适合

  • 读多写少
  • 热点集中
  • 对毫秒级性能敏感
  • 允许短暂最终一致

不适合

  • 强一致要求极高
  • 写入非常频繁
  • 数据实时变化快
  • 应用节点非常多但缺少统一失效机制

如果你的业务是“库存扣减”“账户余额”“支付状态最终确认”,多级缓存一定要谨慎,很多场景甚至不该放缓存。


一个更实用的落地建议

如果你准备把本文方案搬进生产,我建议分三步走:

第一步:先做单层 Redis 缓存

目标是把缓存 Key、TTL、序列化、失效策略统一起来。

第二步:只给热点接口加 L1 本地缓存

不要全量上本地缓存,先挑收益最大的接口。

第三步:补一致性治理

至少补上:

  • 更新后删除两层缓存
  • 热点 Key 互斥重建
  • 空值缓存
  • TTL 随机化
  • 命中率和回源监控

这样推进,风险最小,也最容易看见收益。


总结

Spring Boot 里做 Spring Cache + Redis 很容易,
但要把它做成真正抗打的多级缓存,重点不在“会不会加注解”,而在下面这几个点:

  1. 明确层次职责:L1 本地缓存负责极致性能,L2 Redis 负责共享数据
  2. 处理三大问题:穿透、击穿、雪崩都要有方案
  3. 坚持删除缓存思路:更新 DB 后删缓存,比更新缓存更稳
  4. 多实例要考虑 L1 失效同步
  5. 必须有监控和日志:否则出了问题很难定位

如果你现在正在做一个读多写少、热点明显的系统,这套方案是很值得落地的。
但请记住它的边界:多级缓存换来的是性能,不是强一致。

最后给一个实操建议,适合大多数中级开发者直接执行:

  • 先用 Redis 跑通
  • 再给最热点接口加 Caffeine
  • 更新路径统一“更新 DB + 删除两层缓存”
  • 空值缓存 TTL 设短
  • 热点 Key 加锁重建
  • 用监控验证命中率,而不是凭感觉优化

这样做,基本就能从“能用缓存”,走到“缓存用得稳”。


分享到:

上一篇
《集群架构实战:从单体迁移到高可用 Kubernetes 集群的设计、部署与容量规划》
下一篇
《从抓包到算法还原:中级开发者实战 Web 逆向中的签名参数分析与自动化复现》