一、什么是缓存

1.1 缓存的定义

缓存(Cache)是数据交换的缓冲区,作为存储数据的临时场所,具备高读写性能特点。其核心作用在于通过暂存高频访问数据,减少对后端数据源的直接访问压力,从而显著提升系统响应速度。

1.2 缓存的分类

different-cache.png

  • 浏览器缓存:存储浏览器访问过的静态资源(如HTML、CSS、JS等),减少重复请求。

  • 应用层缓存:位于应用服务器层(如Tomcat),缓存应用运行过程中产生的中间数据。

  • 数据库缓存:数据库自身提供的缓存机制(如MySQL的InnoDB缓冲池),加速数据查询。

  • CPU缓存:CPU内部的高速存储单元,用于存储频繁访问的指令和数据,降低CPU访问内存的延迟。

  • 磁盘缓存:操作系统对磁盘操作的缓存,减少磁盘I/O次数,提升数据读写效率。

1.3 缓存的作用与成本

  • 作用

    • 降低后端负载:减少对数据库等后端数据源的直接访问频次。

    • 提高读写效率:通过内存等高速存储介质,降低响应时间。

  • 成本

    • 数据一致性成本:缓存数据与数据源可能存在不一致风险。

    • 代码维护成本:需要额外编写缓存操作逻辑,增加代码复杂度。

    • 运维成本:涉及缓存集群的部署、监控和维护。

二、添加Redis缓存

2.1 缓存作用模型

cache-model.png

客户端请求数据时,先查询Redis缓存:

  • 若命中缓存,直接返回数据。

  • 若未命中缓存,再查询数据库,并将查询结果写入Redis缓存,供后续请求使用。

2.2 查询流程示例:根据ID查询商铺

queryByCache.png

  1. 提交商铺ID请求。

  2. 判断Redis缓存是否命中:

    1. 命中:直接返回商铺信息。

    2. 未命中:从数据库查询商铺数据。

  3. 若数据库存在该商铺数据,将其写入Redis缓存;若不存在,返回404。

2.3 实战练习:为店铺类型查询添加缓存

需求:修改ShopTypeControllerqueryTypeList方法,添加Redis缓存。

@GetMapping("list")
public Result queryTypeList() {
    // 先从Redis查询缓存
    String cacheKey = "shop:types";
    String cacheData = stringRedisTemplate.opsForValue().get(cacheKey);
    if (StringUtils.isNotBlank(cacheData)) {
        return Result.ok(JSONUtil.toList(cacheData, ShopType.class));
    }
​
    // 缓存未命中,查询数据库
    List<ShopType> typeList = typeService.query().orderByAsc("sort").list();
    if (typeList.isEmpty()) {
        return Result.ok();
    }
​
    // 写入缓存(设置TTL为1小时)
    stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(typeList), 1, TimeUnit.HOURS);
    return Result.ok(typeList);
}

三、缓存更新策略

3.1 三种更新策略对比

策略

说明

一致性

维护成本

适用场景

内存淘汰

依赖Redis内存淘汰机制(如LRU),内存不足时自动淘汰数据

低一致性需求(如店铺类型查询)

超时剔除

为缓存设置TTL时间,到期自动删除

一般

中等一致性需求

主动更新

修改数据库时同步更新缓存

高一致性需求(如店铺详情查询)

3.2 主动更新策略详解(Cache Aside Pattern)

  • 操作顺序:先写数据库,再删除缓存(避免脏读)。

  • 原子性保证

    • 单体系统:将数据库与缓存操作置于同一事务。

    • 分布式系统:使用TCC等分布式事务方案。

  • 线程安全问题:通过互斥锁避免多线程并发更新导致的缓存与数据库不一致。

3.3 最佳实践

  • 低一致性需求:优先使用内存淘汰机制。

  • 高一致性需求:采用“主动更新+超时剔除”组合策略。

四、缓存穿透

4.1 问题定义

指大量请求直接绕过缓存层,直达数据库,导致数据库压力激增的现象(如请求不存在的数据)。

Cache Penetration.png

4.2 解决方案

SolvedCachePenetration-GYFN.png

  1. 缓存空对象

    1. 流程:当数据库查询结果为空时,将null值写入缓存(设置短TTL)。

    2. 优点:实现简单,维护方便。

    3. 缺点:浪费内存,可能存在短期数据不一致。

  2. 布隆过滤器

    1. 流程:在请求进入数据库前,通过布隆过滤器过滤无效请求。

    2. 优点:内存占用少,无多余Key。

    3. 缺点:实现复杂,存在误判可能。

  3. 其他辅助措施:增强ID复杂度、校验数据格式、限流等。

4.3 查询流程优化

image-tZya.png

五、缓存雪崩

5.1 问题定义

指缓存层大面积失效(如服务器宕机、大量 Key 同时过期),导致请求流量集中涌入数据库,造成数据库崩溃的现象。

image-tgpI.png

5.2 解决方案

  • 随机TTL:为不同Key设置随机过期时间,避免集中失效。

  • Redis集群:通过主从复制和分片机制提高服务可用性。

  • 降级限流:对非核心业务进行限流或返回降级数据。

  • 多级缓存:结合本地缓存(如Caffeine)和分布式缓存(Redis),降低对单一缓存的依赖。

六、缓存击穿

6.1 问题定义

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

6.2 解决方案

方案

核心逻辑

优点

缺点

互斥锁(Lock)

在缓存重建期间,通过分布式锁保证只有一个线程访问数据库

实现简单,保证一致性

线程需等待,可能引入死锁

逻辑过期

在缓存数据中存储逻辑过期时间,通过异步线程更新缓存

无锁竞争,性能高

存在短暂数据不一致,实现复杂

6.3 实战案例:互斥锁方案

public Shop queryWithLock(Long shopId) {
    String cacheKey = "shop:info:" + shopId;
    // 1. 查询缓存
    String cacheData = stringRedisTemplate.opsForValue().get(cacheKey);
    if (StringUtils.isNotBlank(cacheData)) {
        return JSONUtil.toBean(cacheData, Shop.class);
    }
​
    // 2. 缓存未命中,尝试获取互斥锁
    String lockKey = "lock:shop:" + shopId;
    Shop shop = null;
    try {
        boolean isLock = tryLock(lockKey);
        if (!isLock) {
            // 未获取到锁,休眠重试
            Thread.sleep(50);
            return queryWithLock(shopId);
        }
​
        // 3. 获取锁成功,查询数据库
        shop = shopService.getById(shopId);
        if (shop == null) {
            // 缓存空值解决穿透
            stringRedisTemplate.opsForValue().set(cacheKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
​
        // 4. 写入缓存
        stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_TTL, TimeUnit.HOURS);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        // 5. 释放锁
        unlock(lockKey);
    }
    return shop;
}

七、缓存工具封装

7.1 需求说明

基于StringRedisTemplate封装通用缓存工具类,实现以下功能:

  1. 存储对象并设置TTL:将Java对象序列化为JSON,存入String类型Key,设置过期时间。

  2. 存储对象并设置逻辑过期:用于处理缓存击穿场景。

  3. 查询缓存并反序列化(空值处理):解决缓存穿透问题。

  4. 查询缓存并反序列化(逻辑过期处理):结合逻辑过期方案处理热点Key问题。

7.2 核心代码示例

public class CacheUtils {
    private static final String LOGICAL_EXPIRE_KEY = "logical:expire:";
​
    /**
     * 存储对象并设置TTL
     */
    public static void setWithTTL(String key, Object value, long timeout, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), timeout, unit);
    }
​
    /**
     * 存储对象并设置逻辑过期时间
     */
    public static void setWithLogicalExpire(String key, Object value, long timeout, TimeUnit unit) {
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(System.currentTimeMillis() + unit.toMillis(timeout));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }
​
    /**
     * 查询缓存并处理空值(穿透解决方案)
     */
    public static <R, T> R queryWithPassThrough(String keyPrefix, Long id, Class<T> type,
                                               Function<Long, R> dbFallback, long timeout, TimeUnit unit) {
        String key = keyPrefix + id;
        String cacheData = stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isNotBlank(cacheData)) {
            return JSONUtil.toBean(cacheData, type);
        }
        if (cacheData != null) { // 处理缓存空值
            return null;
        }
​
        // 查询数据库
        R result = dbFallback.apply(id);
        if (result == null) {
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        setWithTTL(key, result, timeout, unit);
        return result;
    }
}

八、总结

本文围绕商户查询场景,深入解析了缓存技术的核心概念与实践方案,重点涵盖:

  • 缓存的基础原理与分类。

  • Redis缓存的集成与查询流程设计。

  • 缓存更新策略的选择与实现。

  • 缓存穿透、雪崩、击穿三大典型问题的成因与解决方案。

  • 通用缓存工具类的封装思路。

通过合理应用缓存技术,可显著提升系统性能与稳定性,但需结合业务场景权衡一致性、性能与维护成本,选择最优方案。