|

第二章:商户查询缓存模块


📺 对应集数:p035~p047(实战篇)

2.1 认识缓存

2.1.1 什么是缓存

缓存(Cache) = 数据交换的缓冲区,存储数据的临时地方,读写性能较高

💡 核心要点:缓存的价值在于”读写性能高”,这是它被用作数据交换缓冲区的根本原因。

经典案例 - CPU 缓存: - CPU 运算能力远超内存/磁盘的读写能力 - CPU 从内存/磁盘读数据的速度成为性能瓶颈 - 解决方案:在 CPU 内部添加缓存,将常用数据存入 - 衡量 CPU 性能的标准之一:CPU 缓存大小

2.1.2 Web 应用中的缓存层级

请求流向:
客户端(浏览器) → Nginx(反向代理) → Tomcat(应用层) → Redis → MySQL → 磁盘
      ↓               ↓                ↓            ↓
   浏览器缓存       Nginx缓存        应用层缓存    数据库缓存
  (静态资源)      (反向代理缓存)    (Map/Redis)   (索引缓存)
层级 缓存方式 示例
浏览器缓存 本地缓存静态资源 CSS、JS、图片
应用层缓存 内存/Redis 缓存 商户信息、用户数据
数据库缓存 索引缓存 MySQL 聚簇索引

2.1.3 缓存的作用与成本

✅ 带来的好处

  1. 降低后端负载:请求直接从缓存返回,减轻数据库压力
  2. 提高读写效率:Redis 读写延迟在微秒级别,大大缩短响应时间
  3. 应对高并发:高用户量业务中,缓存可有效解决高并发问题

❌ 带来的成本

  1. 数据一致性成本:数据库更新后缓存未同步,导致数据不一致
  2. 开发维护成本:解决一致性问题需要复杂编码(穿透、击穿等)
  3. 运维成本:缓存集群的部署、维护需要额外人力和硬件成本

⚠️ 决策原则:企业需要权衡缓存带来的好处能否弥补成本。中小型初创企业用户量不大时,不一定需要引入缓存。


2.2 实现商户缓存查询

2.2.1 缓存工作模型

无缓存:
客户端 → 数据库 → 返回

有缓存:
客户端 → Redis(命中)→ 直接返回
     ↓ (未命中)
    数据库 → 返回 + 写入Redis

关键点: - 未命中时查数据库,查到后写回 Redis,确保下次能命中 - 随着请求增多,Redis 命中率逐渐提高,形成良性循环

2.2.2 业务流程图

开始 → 提交商铺ID
  ↓
查询 Redis 缓存
  ↓
判断是否命中?
  ├─ 命中 → 直接返回商铺信息
  └─ 未命中 → 根据ID查询数据库
                ↓
              判断是否存在?
                ├─ 不存在 → 返回 404
                └─ 存在 → 写入Redis → 返回商铺信息

2.2.3 代码实现

Service 层核心代码

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        // 1. 从Redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue()
                .get("cache:shop:" + id);

        // 2. 判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3. 存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }

        // 4. 不存在,查询数据库
        Shop shop = getById(id);
        if (shop == null) {
            // 5. 不存在,返回错误
            return Result.fail("店铺不存在!");
        }

        // 6. 存在,写入Redis
        stringRedisTemplate.opsForValue()
                .set("cache:shop:" + id, JSONUtil.toJsonStr(shop));

        // 7. 返回
        return Result.ok(shop);
    }
}

Key 设计规范

格式:cache:shop:{id}
示例:cache:shop:1

💡 两个关键点: 1. 查询时先查 Redis 2. 未命中时查数据库,然后写回 Redis


2.3 缓存更新策略

2.3.1 三种策略对比

策略 一致性 维护成本 说明
内存淘汰 几乎为0 Redis 内存不足时自动淘汰部分数据
超时剔除 一般 给 key 设置 TTL,到期自动删除
主动更新 自定义逻辑,更新数据库时同步更新缓存

2.3.2 策略选择建议

  • 低一致性需求:使用内存淘汰机制(如店铺类型,几乎不变)
  • 高一致性需求:使用主动更新 + 超时剔除兜底(如店铺详情、优惠券)

2.3.3 主动更新的三种模式

模式 说明 优缺点
Cache Aside(旁路缓存) 由开发者自己编码,更新数据库同时更新缓存 可控性强,企业常用
Read/Write Through 缓存数据库整合成一个服务,对外透明 对调用者简单,但服务开发复杂
Write Behind Caching 只操作缓存,异步线程定时将缓存数据同步到数据库 写效率高,但一致性难保证

2.3.4 Cache Aside 的关键问题

问题一:删除缓存 vs 更新缓存?

选择删除缓存: - 更新缓存:每次更新数据库都要更新缓存,写多读少时有大量无效操作 - 删除缓存:更新数据库时删缓存,有人查询时才更新,属于延迟加载

问题二:如何保证原子性?

  • 单体系统:利用数据库事务,保证更新数据库和删除缓存同时成功或失败
  • 分布式系统:使用分布式事务方案(如 TCC)保证原子性

问题三:先删缓存还是先操作数据库?

选择先操作数据库,再删缓存: - 方案一(先删缓存再操作数据库):线程安全问题发生概率较高 - 方案二(先操作数据库再删缓存):线程安全问题发生概率极低

方案二线程安全分析

正常情况:更新数据库(20) → 删缓存 → 用户查询 → 查数据库(20) → 写缓存(20) ✅

异常情况(概率极低,需满足3个巧合): 1. 两个线程并发执行 2. 查询线程恰好缓存失效 3. 在查询线程写缓存的微秒级时间内,更新线程完成了数据库更新+删缓存

因为缓存写操作是微秒级,而数据库更新较慢,所以同时满足这3个巧合的概率极低。

2.3.5 最终最佳实践

写操作:
更新数据库 → 删除缓存(保证原子性,先操作数据库再删缓存)

读操作:
查询Redis → 命中直接返回
    ↓ 未命中
查询数据库 → 写入Redis(设置TTL作为兜底)→ 返回

2.4 实现缓存同步(代码)

2.4.1 查询业务(添加 TTL)

// 写入Redis时设置过期时间(30分钟)
stringRedisTemplate.opsForValue()
        .set("cache:shop:" + id, JSONUtil.toJsonStr(shop), 
             30, TimeUnit.MINUTES);

2.4.2 更新业务(先更新DB,再删缓存)

@Transactional
@Override
public Result update(Shop shop) {
    Long id = shop.getId();
    if (id == null) {
        return Result.fail("店铺id不能为空");
    }

    // 1. 更新数据库
    updateById(shop);

    // 2. 删除缓存
    stringRedisTemplate.delete("cache:shop:" + id);

    return Result.ok();
}

⚠️ @Transactional 确保更新数据库和删除缓存的原子性


2.5 缓存穿透

2.5.1 问题描述

缓存穿透:客户端请求的数据在缓存和数据库中都不存在,请求永远无法建立缓存,每次都会打到数据库。

恶意攻击场景:
用户传入不存在的ID → Redis未命中 → 数据库未命中 → 返回空
       ↓
再次请求相同ID → 同样的流程 → 数据库持续承压

2.5.2 解决方案对比

方案 原理 优点 缺点
缓存空值 查询不存在时缓存 null 值 实现简单 额外内存消耗、短期不一致
布隆过滤 在 Redis 前加过滤层,基于位图判断 内存占用少 实现复杂、存在误判

2.5.3 方案一:缓存空值(代码实现)

流程变化

原流程:数据库不存在 → 返回 404
新流程:数据库不存在 → 写入空值到Redis → 返回错误
       ↓
     命中时判断:是空值 → 返回错误;是真实数据 → 返回数据

核心代码

@Override
public Result queryById(Long id) {
    String shopJson = stringRedisTemplate.opsForValue()
            .get("cache:shop:" + id);

    // 1. 判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }

    // 判断是否命中的是空值
    if (shopJson != null) {
        // 命中空值,返回错误
        return Result.fail("店铺不存在!");
    }

    // 2. 查询数据库
    Shop shop = getById(id);
    if (shop == null) {
        // 不存在,缓存空值(设置较短TTL,如2分钟)
        stringRedisTemplate.opsForValue()
                .set("cache:shop:" + id, "", 
                     2, TimeUnit.MINUTES);
        return Result.fail("店铺不存在!");
    }

    // 3. 存在,写入Redis
    stringRedisTemplate.opsForValue()
            .set("cache:shop:" + id, JSONUtil.toJsonStr(shop), 
                 30, TimeUnit.MINUTES);

    return Result.ok(shop);
}

2.5.4 其他主动防御措施

  • 增加 ID 复杂度:避免简单数字 ID(如 0),采用一定格式规范(长度、前缀)
  • 基础参数校验:在进入业务逻辑前检查 ID 格式是否合法
  • 用户权限管理:要求登录、限制访问频率
  • 热点参数限流:对空值访问进行限流

2.6 缓存雪崩

2.6.1 问题描述

缓存雪崩:同一时段内大量缓存 key 同时失效,或 Redis 服务宕机,导致大量请求到达数据库。

两种场景: 1. 大量 key 同时失效:缓存预热批量导入时 TTL 设置相同,到期一起失效 2. Redis 宕机:所有请求直接打到数据库,数据库裸奔

2.6.2 解决方案

方案 针对问题 说明
TTL 添加随机值 key 同时失效 批量导入时给 TTL 加随机偏移(如 30+随机1~5分钟)
Redis 集群(哨兵) Redis 宕机 主从集群 + 哨兵自动故障转移,保证高可用
服务降级和限流 严重故障 Redis 故障时快速失败,拒绝服务,保护数据库
多级缓存 综合保护 浏览器 → Nginx → Redis → JVM本地缓存 → 数据库

💡 多级缓存就像穿5件防弹衣,一层一层保护,即使某一环崩了还有其他缓存。


2.7 缓存击穿

2.7.1 问题描述

缓存击穿(热点 key 问题):被高并发访问缓存重建业务复杂的热点 key 过期,导致大量请求同时涌入数据库。

特点: 1. 高并发访问:该 key 被大量请求同时访问 2. 缓存重建耗时长:可能需要关联多个表、复杂运算,耗时几十到数百毫秒

问题场景:
热点key过期 → 第1个请求查缓存未命中 → 开始重建(耗时200ms)
    ↓
在重建的200ms内 → 无数请求涌入 → 全部未命中 → 全部打到数据库 → 数据库崩溃

2.7.2 两种解决方案对比

对比维度 互斥锁方案 逻辑过期方案
实现方式 利用 SETNX 实现互斥锁 数据永不过期,逻辑上维护过期时间
一致性 ✅ 强一致性 ❌ 不保证(可能返回旧数据)
性能 ❌ 等待导致性能下降 ✅ 无需等待,性能好
额外内存 需存储过期时间字段
代码复杂度 简单 复杂
死锁风险

2.7.3 方案一:互斥锁

原理:只有一个线程能获取锁执行缓存重建,其他线程等待/重试。

线程1(获取锁成功):
查询缓存未命中 → 获取锁成功 → 查数据库 → 写入Redis → 释放锁 → 返回

线程2(获取锁失败):
查询缓存未命中 → 获取锁失败 → 休眠50ms → 重试查询 → 命中 → 返回

实现互斥锁(基于 SETNX)

// 获取锁
private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue()
            .setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}

// 释放锁
private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

💡 SETNX 命令:只有 key 不存在时才能设置成功,天然适合做互斥锁。 必须设置 TTL(如10秒)防止程序异常导致锁永远无法释放。

完整代码实现

public Shop queryWithMutex(Long id) {
    String key = "cache:shop:" + id;
    String lockKey = "lock:shop:" + id;

    // 1. 查Redis
    String shopJson = stringRedisTemplate.opsForValue().get(key);

    // 2. 判断是否命中
    if (StrUtil.isNotBlank(shopJson)) {
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    if (shopJson != null) {
        return null; // 命中空值
    }

    // 3. 未命中,尝试获取互斥锁
    Shop shop = null;
    try {
        boolean isLock = tryLock(lockKey);
        if (!isLock) {
            // 获取锁失败,休眠后重试
            Thread.sleep(50);
            return queryWithMutex(id); // 递归重试
        }

        // 获取锁成功,查询数据库
        shop = getById(id);
        if (shop == null) {
            // 缓存空值(解决穿透)
            stringRedisTemplate.opsForValue()
                    .set(key, "", 2, TimeUnit.MINUTES);
            return null;
        }

        // 写入Redis
        stringRedisTemplate.opsForValue()
                .set(key, JSONUtil.toJsonStr(shop), 
                     30, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        // 释放锁
        unlock(lockKey);
    }
    return shop;
}

2.7.4 方案二:逻辑过期

原理:数据存储时不设置 TTL(永不过期),在 value 中额外存储一个逻辑过期时间字段。

数据结构

@Data
public class RedisData<T> {
    private LocalDateTime expireTime; // 逻辑过期时间
    private T data;                   // 实际数据
}

缓存预热(活动前提前加载):

public void saveShopToRedis(Long id, Long expireSeconds) {
    // 1. 查询数据库
    Shop shop = getById(id);

    // 2. 封装逻辑过期时间
    RedisData<Shop> redisData = new RedisData<>();
    redisData.setData(shop);
    redisData.setExpireTime(
            LocalDateTime.now().plusSeconds(expireSeconds));

    // 3. 写入Redis(不设置TTL)
    stringRedisTemplate.opsForValue()
            .set("cache:shop:" + id, JSONUtil.toJsonStr(redisData));
}

查询逻辑

private static final ExecutorService CACHE_REBUILD_EXECUTOR = 
        Executors.newFixedThreadPool(10);

public Shop queryWithLogicalExpire(Long id) {
    String key = "cache:shop:" + id;

    // 1. 查Redis
    String json = stringRedisTemplate.opsForValue().get(key);

    // 2. 未命中(非热点key),直接返回null
    if (StrUtil.isBlank(json)) {
        return null;
    }

    // 3. 命中,反序列化
    RedisData<Shop> redisData = JSONUtil.toBean(json, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();

    // 4. 判断逻辑是否过期
    if (expireTime.isAfter(LocalDateTime.now())) {
        // 未过期,直接返回旧数据
        return shop;
    }

    // 5. 已过期,尝试获取互斥锁
    String lockKey = "lock:shop:" + id;
    boolean isLock = tryLock(lockKey);
    if (isLock) {
        // 获取锁成功,开启独立线程重建缓存
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                saveShopToRedis(id, 30L); // 重建缓存
            } finally {
                unlock(lockKey); // 释放锁
            }
        });
    }

    // 6. 返回旧数据(无论是否获取到锁)
    return shop;
}

💡 关键区别: - 互斥锁:未获取到锁的线程等待重试,保证强一致性 - 逻辑过期:未获取到锁的线程直接返回旧数据,保证高性能


2.8 工具类封装

2.8.1 四个核心方法

方法 功能 解决问题
set(key, value, time, unit) 存储任意对象到Redis,带TTL 基础存储
setWithLogicalExpire(key, value, time, unit) 存储带逻辑过期时间的数据 热点key预热
queryWithPassThrough(...) 查询并解决缓存穿透 缓存穿透
queryWithLogicalExpire(...) 查询并解决缓存击穿 缓存击穿

2.8.2 核心代码

@Component
@Slf4j
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    // 存储数据(带TTL)
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue()
                .set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    // 存储数据(带逻辑过期时间)
    public void setWithLogicalExpire(String key, Object value, 
                                      Long time, TimeUnit unit) {
        RedisData<Object> redisData = new RedisData<>();
        redisData.setData(value);
        redisData.setExpireTime(
                LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        stringRedisTemplate.opsForValue()
                .set(key, JSONUtil.toJsonStr(redisData));
    }

    // 解决缓存穿透的查询方法
    public <R, ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type,
            Function<ID, R> dbFallback, Long time, TimeUnit unit) {

        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);

        if (StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }
        if (json != null) {
            return null; // 命中空值
        }

        // 查询数据库
        R r = dbFallback.apply(id);
        if (r == null) {
            // 缓存空值
            this.set(key, "", time, unit);
            return null;
        }

        // 写入Redis
        this.set(key, r, time, unit);
        return r;
    }

    // 解决缓存击穿的查询方法(逻辑过期)
    public <R, ID> R queryWithLogicalExpire(
            String keyPrefix, ID id, Class<R> type,
            Function<ID, R> dbFallback, Long time, TimeUnit unit) {

        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);

        if (StrUtil.isBlank(json)) {
            return null;
        }

        RedisData<R> redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();

        if (expireTime.isAfter(LocalDateTime.now())) {
            return r; // 未过期,返回旧数据
        }

        // 已过期,尝试获取锁重建
        String lockKey = "lock:shop:" + id;
        boolean isLock = tryLock(lockKey);
        if (isLock) {
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    R r1 = dbFallback.apply(id);
                    this.setWithLogicalExpire(key, r1, time, unit);
                } finally {
                    unlock(lockKey);
                }
            });
        }
        return r;
    }
}

2.8.3 封装技巧

难点一:泛型的使用

返回值类型不确定时,使用泛型

// R = 返回值类型,ID = 参数类型
public <R, ID> R queryWithPassThrough(...)

难点二:函数式编程

数据库查询逻辑不确定,由调用者传递函数:

// Function<ID, R>:有参有返回值的函数
Function<ID, R> dbFallback

// 使用时传入 Lambda
cacheClient.queryWithPassThrough(
    "cache:shop:", id, Shop.class,
    Shop::getById,  // 函数引用:根据ID查数据库
    30L, TimeUnit.MINUTES
);

2.9 总结

知识点全景图

商户查询缓存
├── 1. 认识缓存
│   ├── 什么是缓存(缓冲区、读写性能高)
│   ├── 缓存层级(浏览器 → Nginx → 应用 → 数据库)
│   └── 作用与成本(降低负载 / 一致性、开发、运维成本)
│
├── 2. 实现缓存查询
│   ├── 工作模型(先查缓存,未命中查DB并写回)
│   └── 代码实现(String + JSON)
│
├── 3. 缓存更新策略
│   ├── 内存淘汰(Redis自动,一致性差)
│   ├── 超时剔除(TTL,一致性一般)
│   └── 主动更新(Cache Aside,先更新DB再删缓存)
│
├── 4. 缓存穿透(数据都不存在)
│   ├── 缓存空值(简单,有额外内存开销)
│   └── 布隆过滤(内存少,有误判)
│
├── 5. 缓存雪崩(大量key同时失效/Redis宕机)
│   ├── TTL随机值
│   ├── Redis集群高可用
│   └── 降级限流 + 多级缓存
│
├── 6. 缓存击穿(热点key过期)
│   ├── 互斥锁(强一致,性能差,有死锁风险)
│   └── 逻辑过期(高性能,不一致,无死锁)
│
└── 7. 工具类封装
    ├── 泛型处理不确定类型
    └── 函数式编程传递查询逻辑

最佳实践速查

场景 策略
读操作 先查Redis → 未命中查DB → 写回Redis(带TTL)
写操作 先更新DB → 再删Redis(保证原子性)
缓存穿透 缓存空值(TTL 2分钟) + 参数校验
缓存雪崩 TTL随机值 + Redis集群 + 降级限流
缓存击穿(一致性优先) 互斥锁
缓存击穿(性能优先) 逻辑过期

📝 本章要点:缓存是提升系统性能的关键手段,但引入缓存后需要妥善处理一致性、穿透、雪崩、击穿等问题。核心原则是”先更新数据库,再删除缓存”,配合 TTL 兜底,形成完整的缓存最佳实践。

评论交流

文章目录