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

《Spring Boot 中级实战:基于 Spring Cache + Redis 构建高并发场景下的多级缓存与一致性方案》

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

背景与问题

在 Spring Boot 项目里,很多人第一次做缓存,往往是这样的路径:

  1. 先给查询接口加 @Cacheable
  2. 底层接 Redis
  3. 上线后发现 QPS 是上来了,但问题也跟着来了

典型现象包括:

  • 热点 Key 被瞬时打爆,Redis 压力飙升
  • 本地 JVM 明明还有大量空闲内存,却每次都要走远程 Redis
  • 更新数据库后,缓存偶发读到旧值
  • 多实例部署后,A 节点更新了缓存,B 节点仍然读旧数据
  • 某个大 Key 过期时,流量瞬间回源数据库

这时候,单一 Redis 缓存通常不够了。更稳妥的思路是:

本地缓存(L1) + Redis 缓存(L2) + 数据库(DB)
同时配合合理的一致性策略、失效策略和防击穿/穿透/雪崩手段。

如果你已经会用 Spring Cache,那么这篇文章重点不是教你“怎么加注解”,而是带你把它真正用到高并发场景里,形成一套能上线、能排障、能扩展的方案。


方案目标与取舍分析

先说结论:多级缓存不是“永远更好”,而是用空间换延迟、用复杂度换吞吐。

为什么要做多级缓存

单 Redis 缓存的问题主要在于:

  • 网络 IO 无法避免
  • Redis 仍可能成为瓶颈
  • 热点请求全打到同一层,集中风险大

引入本地缓存后:

  • L1 本地缓存:纳秒到微秒级访问,适合热点数据
  • L2 Redis:跨实例共享,容量更大
  • DB:最终数据源

典型适用场景

适合做多级缓存的场景:

  • 商品详情、类目树、配置项、字典数据
  • 用户画像摘要、首页聚合结果
  • 热门榜单、推荐结果快照
  • 读多写少、允许短暂最终一致

不太适合直接套用的场景:

  • 强一致金融数据
  • 秒级内频繁更新且读写比接近 1:1
  • 大对象且更新成本非常高的数据

方案对比

方案优点缺点适用场景
仅本地缓存极快、无网络开销多实例不一致、容量受限单机场景、开发环境
仅 Redis 缓存架构简单、共享一致网络开销、热点集中中小规模读缓存
本地 + Redis 多级缓存延迟低、抗压强实现复杂、一致性成本高高并发读多写少
旁路缓存 + MQ 广播失效一致性更好链路更长多实例、高一致要求

我的经验是:如果你已经是多实例部署、接口存在明显热点、Redis QPS 有压力,那么多级缓存基本值得做。


核心原理

多级缓存的核心不在“加两层”,而在于读写路径设计一致性边界控制

1. 读路径:Cache Aside + 多级查找

标准流程:

  1. 先查本地缓存 L1
  2. L1 未命中,再查 Redis L2
  3. L2 命中后回填 L1
  4. L2 也未命中,再查数据库
  5. 数据库查到后写入 L2 和 L1
flowchart TD
    A[请求到达] --> B{L1本地缓存命中?}
    B -- 是 --> C[直接返回]
    B -- 否 --> D{L2 Redis命中?}
    D -- 是 --> E[回填L1]
    E --> C
    D -- 否 --> F[查询数据库]
    F --> G{查到数据?}
    G -- 是 --> H[写入Redis]
    H --> I[写入L1]
    I --> C
    G -- 否 --> J[写入空值缓存/短TTL]
    J --> K[返回空结果]

2. 写路径:先更新库,再删除缓存

缓存一致性里,一个常见原则是:

更新数据库后,删除缓存,而不是直接更新缓存。

为什么?

  • 直接更新两层缓存容易出现部分成功、部分失败
  • 删除缓存更简单,后续读请求可触发重新加载
  • 搭配延迟双删或消息通知,可进一步降低脏读概率

典型写流程:

  1. 更新数据库
  2. 删除 Redis 缓存
  3. 删除本地缓存
  4. 必要时广播其他节点删除本地缓存
sequenceDiagram
    participant Client as Client
    participant App as App实例A
    participant DB as MySQL
    participant Redis as Redis
    participant MQ as MQ/Redis PubSub
    participant AppB as App实例B

    Client->>App: 更新请求
    App->>DB: update
    DB-->>App: success
    App->>Redis: delete key
    App->>MQ: 发布失效消息
    MQ-->>AppB: 通知删除本地缓存
    App-->>Client: 返回成功

3. 一致性本质:不是“绝对一致”,而是“可控的不一致窗口”

在分布式系统里,缓存和数据库天然是两套存储。你需要接受一个现实:

  • 强一致成本很高
  • 大多数业务追求的是最终一致 + 可观测 + 可恢复

所以设计时要明确边界:

  • 允许几百毫秒到几秒的不一致吗?
  • 是否能接受读到旧值但不能接受写丢失?
  • 如果本地缓存和 Redis 短时不一致,业务是否可容忍?

这几个问题,决定了你是用简单删除策略,还是要引入消息队列、版本号、逻辑过期甚至分布式锁。


架构设计:一个可落地的多级缓存模型

这里我给出一个比较实用的中级方案:

  • L1:Caffeine 本地缓存
  • L2:Redis
  • Cache API:Spring Cache
  • 一致性通知:Redis Pub/Sub(或 MQ)
  • 防击穿:热点 Key 加锁 / sync = true
  • 防穿透:空值缓存
  • 防雪崩:TTL 随机化

整体架构图

flowchart LR
    U[用户请求] --> A[Spring Boot实例1]
    U --> B[Spring Boot实例2]

    A --> A1[L1 Caffeine]
    B --> B1[L1 Caffeine]

    A --> R[Redis L2]
    B --> R

    R --> D[MySQL]

    A --> P[Pub/Sub]
    B --> P

缓存层职责建议

L1 本地缓存

职责:

  • 吸收热点流量
  • 降低 Redis 压力
  • 提供极低访问延迟

特点:

  • 容量小
  • TTL 短
  • 只存热点、小对象、读多写少数据

L2 Redis

职责:

  • 跨实例共享
  • 存储相对更大范围的数据
  • 缓冲数据库压力

特点:

  • TTL 可略长于本地缓存
  • 适合做统一缓存层
  • 可结合监控分析命中率和热点分布

实战代码(可运行)

下面给出一套简化但能跑的示例。为了突出多级缓存思路,我用一个“商品详情查询”场景来演示。

1. 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>

2. application.yml

server:
  port: 8080

spring:
  cache:
    type: none
  redis:
    host: 127.0.0.1
    port: 6379
    timeout: 2000ms

logging:
  level:
    org.springframework.cache: debug

这里我把 spring.cache.type 设成 none,原因是我们要自己组合多级缓存,而不是只用默认单一实现。

3. 启动类

package com.example.multicache;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

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

4. 实体类

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;

    public Product() {
    }

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

    public Long getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }

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

    public BigDecimal getPrice() {
        return price;
    }

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

5. 模拟数据库 Repository

package com.example.multicache.repository;

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

import jakarta.annotation.PostConstruct;
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("399.00")));
        db.put(2L, new Product(2L, "显示器", new BigDecimal("1299.00")));
    }

    public Product findById(Long id) {
        simulateSlowQuery();
        return db.get(id);
    }

    public Product updatePrice(Long id, BigDecimal price) {
        Product product = db.get(id);
        if (product != null) {
            product.setPrice(price);
        }
        return product;
    }

    private void simulateSlowQuery() {
        try {
            Thread.sleep(150);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

6. 多级缓存配置

这里我们自定义一个 CacheManager,返回一个组合缓存:先本地、再 Redis。

package com.example.multicache.config;

import com.example.multicache.cache.MultiLevelCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.*;

import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.ConcurrentHashMap;

@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisSerializationContext.SerializationPair<Object> valueSerializationPair =
                RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer());

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(valueSerializationPair);

        RedisCacheManager redisCacheManager = new RedisCacheManager(
                RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory),
                redisCacheConfiguration
        );

        return new CacheManager() {
            private final ConcurrentHashMap<String, Cache> cacheMap = new ConcurrentHashMap<>();

            @Override
            public Cache getCache(String name) {
                return cacheMap.computeIfAbsent(name, cacheName -> {
                    Cache redisCache = redisCacheManager.getCache(cacheName);
                    return new MultiLevelCache(
                            cacheName,
                            Caffeine.newBuilder()
                                    .maximumSize(1000)
                                    .expireAfterWrite(Duration.ofSeconds(30))
                                    .build(),
                            redisCache
                    );
                });
            }

            @Override
            public Collection<String> getCacheNames() {
                return Collections.emptyList();
            }
        };
    }

    @Bean
    public GenericJackson2JsonRedisSerializer redisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

7. 多级缓存实现

package com.example.multicache.cache;

import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;

import java.util.concurrent.Callable;

public class MultiLevelCache implements org.springframework.cache.Cache {

    private final String name;
    private final Cache<Object, Object> localCache;
    private final org.springframework.cache.Cache redisCache;

    public MultiLevelCache(String name,
                           Cache<Object, Object> localCache,
                           org.springframework.cache.Cache redisCache) {
        this.name = name;
        this.localCache = localCache;
        this.redisCache = redisCache;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public Object getNativeCache() {
        return this;
    }

    @Override
    public ValueWrapper get(Object key) {
        Object localValue = localCache.getIfPresent(key);
        if (localValue != null) {
            return new SimpleValueWrapper(localValue);
        }

        ValueWrapper redisValue = redisCache.get(key);
        if (redisValue != null && redisValue.get() != null) {
            localCache.put(key, redisValue.get());
            return redisValue;
        }
        return null;
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T get(Object key, Class<T> type) {
        Object localValue = localCache.getIfPresent(key);
        if (localValue != null && type.isInstance(localValue)) {
            return (T) localValue;
        }

        T redisValue = redisCache.get(key, type);
        if (redisValue != null) {
            localCache.put(key, redisValue);
        }
        return redisValue;
    }

    @Override
    public <T> T get(Object key, Callable<T> valueLoader) {
        Object localValue = localCache.getIfPresent(key);
        if (localValue != null) {
            return (T) localValue;
        }

        T redisValue = redisCache.get(key, () -> null);
        if (redisValue != null) {
            localCache.put(key, redisValue);
            return redisValue;
        }

        try {
            T value = valueLoader.call();
            if (value != null) {
                put(key, value);
            }
            return value;
        } catch (Exception e) {
            throw new ValueRetrievalException(key, valueLoader, e);
        }
    }

    @Override
    public void put(Object key, Object value) {
        localCache.put(key, value);
        redisCache.put(key, value);
    }

    @Override
    public ValueWrapper putIfAbsent(Object key, Object value) {
        ValueWrapper existing = get(key);
        if (existing == null) {
            put(key, value);
        }
        return existing;
    }

    @Override
    public void evict(Object key) {
        localCache.invalidate(key);
        redisCache.evict(key);
    }

    @Override
    public boolean evictIfPresent(Object key) {
        localCache.invalidate(key);
        return redisCache.evictIfPresent(key);
    }

    @Override
    public void clear() {
        localCache.invalidateAll();
        redisCache.clear();
    }

    @Override
    public boolean invalidate() {
        localCache.invalidateAll();
        return redisCache.invalidate();
    }
}

8. Service 层

这里演示 @Cacheable@CacheEvict 的配合。

package com.example.multicache.service;

import com.example.multicache.model.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "product", key = "#id", sync = true)
    public Product getProduct(Long id) {
        System.out.println("load from db, id=" + id);
        return productRepository.findById(id);
    }

    @CacheEvict(cacheNames = "product", key = "#id")
    public Product updatePrice(Long id, BigDecimal price) {
        return productRepository.updatePrice(id, price);
    }
}

这里的 sync = true 很有用:

  • 同一个 JVM 内,多个线程同时查同一个未命中 Key
  • 只会有一个线程执行加载逻辑
  • 能减少热点 Key 在本地层的并发击穿

但注意,它只能约束单实例内,对多实例无效。这个坑很多人第一次会忽略。

9. Controller

package com.example.multicache.controller;

import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

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

    private final ProductService productService;

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

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

    @PutMapping("/{id}/price")
    public Product updatePrice(@PathVariable Long id,
                               @RequestParam @NotNull @DecimalMin("0.01") BigDecimal price) {
        return productService.updatePrice(id, price);
    }
}

10. 如何验证多级缓存是否生效

你可以这样测试:

第一次查询

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

预期:

  • 控制台输出 load from db, id=1
  • 数据来自 DB,随后写入 Redis 和本地缓存

第二次查询

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

预期:

  • 不再输出 load from db
  • 命中本地缓存或 Redis

更新价格

curl -X PUT "http://localhost:8080/products/1/price?price=499.00"

再次查询

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

预期:

  • 因为 @CacheEvict 已删除缓存,重新走 DB 加载新值

进一步增强:跨实例本地缓存一致性

上面的代码已经能跑,但还有一个关键问题:

某个实例删掉了自己的本地缓存,其他实例的本地缓存怎么办?

这就是多级缓存里最常见的一致性问题。

思路:失效通知广播

做法一般有两种:

  1. Redis Pub/Sub
  2. MQ(Kafka/RabbitMQ)

当某个节点更新数据后:

  • 删除 Redis 缓存
  • 发布失效消息
  • 所有应用节点收到消息后,删除本地缓存

一个简化的失效消息模型

package com.example.multicache.message;

import java.io.Serializable;

public class CacheMessage implements Serializable {
    private String cacheName;
    private String key;

    public CacheMessage() {
    }

    public CacheMessage(String cacheName, String key) {
        this.cacheName = cacheName;
        this.key = key;
    }

    public String getCacheName() {
        return cacheName;
    }

    public void setCacheName(String cacheName) {
        this.cacheName = cacheName;
    }

    public String getKey() {
        return key;
    }

    public void setKey(String key) {
        this.key = key;
    }
}

实际工程里,我更建议:

  • 消息体包含 cacheNamekeyversiontimestamp
  • 有条件的话加业务类型,方便排障

容量估算与参数建议

很多缓存方案不是死在代码,而是死在参数随便配。

1. 本地缓存大小怎么估

一个简化公式:

本地缓存内存 ≈ 热点对象平均大小 × 热点对象数量 × 冗余系数

比如:

  • 单个商品详情序列化后约 2 KB
  • 热点商品 2 万个
  • 冗余系数按 1.5 算

则单机大约需要:

2 KB × 20000 × 1.5 ≈ 60 MB

如果你的应用单机可用堆内存本来就不多,本地缓存不宜设太大,否则容易增加 GC 压力。

2. TTL 怎么配

推荐原则:

  • L1 TTL 短于 L2 TTL
  • L1:10s ~ 60s
  • L2:5min ~ 30min
  • 再加随机抖动,避免集中过期

示例:

  • 本地缓存:30 秒
  • Redis:10 分钟 + 0~120 秒随机值

3. 命中率怎么看

至少要监控:

  • L1 命中率
  • L2 命中率
  • DB 回源率
  • 热点 Key 分布
  • 缓存平均加载耗时
  • 缓存删除失败次数

经验上:

  • L1 命中率偏低,说明热点识别不准或 TTL 太短
  • L2 命中率偏低,说明缓存策略有问题或大量穿透
  • DB 回源突增,通常是批量失效、雪崩或某层缓存异常

常见坑与排查

这一部分我尽量写得“像现场”,因为很多问题不是不知道原理,而是线上一出事不知道先看哪。

坑 1:@Cacheable 不生效

常见原因

  • 没加 @EnableCaching
  • 方法被 private 修饰
  • 同类内部调用,绕过了 Spring 代理
  • Key 表达式写错
  • 返回值不可序列化,Redis 存储失败

排查建议

先确认这几件事:

1. 该 Bean 是否被 Spring 管理
2. 调用是否经过代理对象
3. 日志里是否有 Cache interceptor 输出
4. Redis 中是否真的产生了对应 key

如果是类内自调用,例如:

public Product a(Long id) {
    return this.getProduct(id);
}

这种情况下缓存注解通常不会生效。我当时第一次踩这个坑,盯着代码看了半天,以为是 Redis 配错了,结果根本没走代理。

坑 2:本地缓存和 Redis 数据不一致

原因

  • 只删了 Redis,没删本地
  • 多实例之间没有广播失效
  • 更新和删除顺序不合理
  • 删除失败没有重试

排查路径

  1. 先看 DB 里的数据是否正确
  2. 再看 Redis key 是否已删除或更新
  3. 最后看实例本地缓存是否仍命中旧值

如果某台机器总是读旧值,而 Redis 已经是新值,八成就是本地缓存未失效

坑 3:缓存击穿

现象

某个热点 Key 过期瞬间,大量请求同时回源 DB。

方案

  • @Cacheable(sync = true):单机内有效
  • Redis 分布式锁:跨实例控制
  • 热点 Key 逻辑过期 + 后台异步刷新
  • 永不过期 + 主动失效

对于超级热点数据,我通常不完全依赖物理 TTL,而是加一层逻辑过期,这样即使缓存“过期”,也不会瞬间全部打到 DB。

坑 4:缓存穿透

现象

查询大量不存在的数据,每次都打到数据库。

方案

  • 缓存空值,TTL 设短一点
  • 布隆过滤器
  • 入参校验,拦截明显非法请求

坑 5:缓存雪崩

现象

大量 Key 同时过期,Redis 或 DB 压力突然暴涨。

方案

  • TTL 加随机值
  • 热点数据预热
  • 多级缓存分担
  • 限流与降级
  • 避免大批量统一时间写入缓存

安全/性能最佳实践

这一节不只是“建议”,很多其实是线上能不能稳住的关键。

1. 不要缓存敏感数据明文

如果缓存里包含:

  • 用户手机号
  • 身份证号
  • token
  • 权限快照

要谨慎处理,至少做到:

  • 敏感字段脱敏
  • 关键数据加密
  • 合理设置 TTL
  • 控制 Redis 访问权限
  • 禁止无鉴权的管理接口暴露缓存内容

2. Key 设计要稳定、可读、可控

推荐格式:

业务前缀:对象类型:主键[:版本]

例如:

product:detail:1
user:profile:1001
config:feature:coupon:v2

不要直接把整个对象 JSON 当 key,也不要让 key 里混入随机无意义参数,否则很难排障和治理。

3. TTL 一定要分层配置

建议:

  • 本地缓存 TTL 更短
  • Redis TTL 更长
  • 对热点 Key 加随机抖动

例如:

L1 = 30s
L2 = 600s + random(0~120s)

4. 大对象要谨慎缓存

一个 500 KB 的对象,哪怕命中率高,也可能带来:

  • Redis 网络开销大
  • 序列化/反序列化成本高
  • 本地缓存占用堆内存
  • Full GC 风险增加

更好的做法:

  • 只缓存必要字段
  • 拆分成多个 Key
  • 聚合结果缓存时控制大小

5. 给缓存失败留退路

缓存不是主存储,所以必须允许失败:

  • Redis 超时后要能回源
  • 本地缓存异常不能拖垮主流程
  • 删除缓存失败要记录日志并告警
  • 关键写操作可做重试或补偿任务

6. 监控比“写对代码”更重要

至少加这些指标:

  • cache.l1.hit
  • cache.l2.hit
  • cache.db.load
  • cache.evict.success
  • cache.evict.fail
  • redis.command.latency
  • cache.rebuild.count

没有监控,你很难知道当前是“缓存生效了”,还是“只是看起来没报错”。


一致性策略进阶建议

如果你的业务对一致性要求更高,可以按复杂度逐级升级:

方案 A:更新库后删除缓存

最简单,适合大部分读多写少场景。

优点:

  • 实现简单
  • 成本低

缺点:

  • 存在短暂脏读窗口

方案 B:延迟双删

流程:

  1. 更新 DB
  2. 删除缓存
  3. 延迟几百毫秒再删一次

适合解决并发下“删缓存后又被旧请求回填”的问题。

但注意:

  • 延迟时间不好拍脑袋定
  • 并不能解决所有极端时序问题

方案 C:删除缓存 + MQ 通知

适合多实例场景,解决本地缓存一致性。

优点:

  • 工程上较平衡
  • 易扩展

缺点:

  • 增加消息链路
  • 需要处理消息丢失、重复消费

方案 D:版本号/逻辑时钟控制

高要求场景可在缓存值里带版本号:

  • 新版本只能覆盖旧版本
  • 避免乱序更新导致旧值覆盖新值

这个方案更复杂,但在复杂并发写场景下很有效。


一个实用的落地建议清单

如果你准备在现有 Spring Boot 项目里上线这套方案,我建议按下面顺序推进:

  1. 先落地 L2 Redis + Spring Cache
  2. 补上命中率、回源率、Redis 延迟监控
  3. 再引入 L1 Caffeine 吸收热点
  4. 更新链路增加缓存删除日志与告警
  5. 多实例场景补失效广播
  6. 针对热点 Key 增加击穿保护
  7. 针对不存在数据增加空值缓存/布隆过滤器
  8. 压测验证 TTL、容量和命中率参数

不要一上来就把所有高级特性堆满。
缓存体系最忌讳“功能很多,但没人说得清什么时候会脏、什么时候会挂、挂了怎么恢复”。


总结

基于 Spring Cache + Redis 做高并发多级缓存,关键不在于注解本身,而在于你是否把下面几件事想清楚了:

  • 读路径:L1 -> L2 -> DB 的回填顺序
  • 写路径:更新库后删除缓存,而不是强行双写
  • 一致性:接受最终一致,控制不一致窗口
  • 多实例问题:本地缓存必须有失效广播机制
  • 高并发风险:击穿、穿透、雪崩都要提前防
  • 可运维性:命中率、回源率、延迟和失败数必须可观测

如果让我给一个中级开发者最实用的建议,那就是:

先实现“简单但正确”的多级缓存,再逐步增强一致性,不要一开始追求完美强一致。

因为在真实项目里,能稳定跑、能快速排障、能解释清楚边界,比“理论最优”更重要。

只要你把本地缓存、Redis、失效通知和监控这四件事串起来,这套方案基本就具备了在高并发业务中落地的价值。


分享到:

上一篇
《分布式架构下的幂等性设计实战:从接口重试到消息消费防重的完整方案》
下一篇
《从源码到部署:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南-255》