代码语言

知识点思维导图

29 个知识节点

Java(31) - 实战——给现有接口加 Redis 缓存

读完后,你应能完成以下任务:

  • 绘制“Java(31) - 实战——给现有接口加 Redis 缓存 / 先回顾:为什么要缓存”的关键对象与数据流,解释“你可以先粗暴地理解为:一个跑在服务器上的、超大号的、所有 Java 进程共享的 Map ,数据存在内存里所以读写极快(微秒级),而且支持给每个 key 设过期时间。”,并用源码位置、日志或 Trace 标注证据。
  • 为“Java(31) - 实战——给现有接口加 Redis 缓存 / Cache Aside 模式:业界最常用的缓存套路”设计正常与异常输入,验证“注意:更新时是「删除缓存」,不是「更新缓存」。”,输出首个偏差位置与回归测试结果。
  • 实现“Java(31) - 实战——给现有接口加 Redis 缓存 / 读流程(查询时)”的最小代码或配置,检验“一句话:先查缓存,命中就返回;”,输出命令、结果与 Diff,并说明不适用边界。

接口慢了不一定要优化 SQL,先想想:这个数据是不是「读多写少」?如果是,加一层缓存往往是性价比最高的提速手段。这一课我们给第 30 课的接口加 Redis 缓存,并对比加缓存前后的差别。


一、先回顾:为什么要缓存

前端同学对「缓存」其实非常熟悉。你在 React 里用过的这些,本质都是缓存:

前端场景 缓存在哪 解决什么
React.useMemo / useCallback 组件内存 避免重复计算
react-query / SWRstaleTime 浏览器内存 避免重复请求后端
localStorage 存用户配置 浏览器磁盘 避免重复登录/拉配置
HTTP Cache-Control 响应头 浏览器 + CDN 避免重复下载静态资源

后端的 Redis 缓存,思路一模一样:把「查一次很贵、但一段时间内不怎么变」的数据,放到一个读取极快的地方(Redis 内存),下次直接拿,不去查数据库。

没有缓存:每次请求都打到 MySQL
  请求 ──→ Controller ──→ Service ──→ Mapper ──→ MySQL(慢,磁盘 IO)

有了缓存:第一次查库并写入 Redis,后续直接读 Redis
  请求 ──→ Controller ──→ Service ──→ Redis(快,纯内存)
                                  └──(没命中)──→ MySQL ──→ 写回 Redis

Redis 是什么? 你可以先粗暴地理解为:一个跑在服务器上的、超大号的、所有 Java 进程共享的 Map<String, String>,数据存在内存里所以读写极快(微秒级),而且支持给每个 key 设过期时间。和前端 localStorage 最大的区别是:localStorage 是「每个浏览器各存各的」,Redis 是「整个后端集群共享同一份」。


二、Cache Aside 模式:业界最常用的缓存套路

加缓存有好几种模式,最常用、也最容易理解的叫 Cache Aside(旁路缓存)。名字唬人,逻辑特别简单,分「读」和「写」两条线。

2.1 读流程(查询时)

        ┌─────────────┐
        │  查 Redis   │
        └──────┬──────┘
               │
        命中? │
       ┌───────┴───────┐
       │ 是            │ 否(没命中 / 过期了)
       ▼               ▼
  直接返回缓存    ┌──────────┐
                  │ 查 MySQL │
                  └────┬─────┘
                       │
                       ▼
                ┌──────────────┐
                │ 写回 Redis   │(顺便设过期时间)
                └──────┬───────┘
                       ▼
                    返回数据

一句话:先查缓存,命中就返回;没命中就查库,查完写回缓存。

这跟你用 react-query 的心智模型几乎一样:

// 前端 react-query 的伪逻辑
const data = cache.get(key)          // 先看缓存
if (data && !isStale(data)) return data   // 命中且没过期,直接用
const fresh = await fetchFromServer() // 没命中,发请求
cache.set(key, fresh)                 // 写回缓存
return fresh

2.2 写流程(更新时)—— 关键是「删缓存」而不是「改缓存」

  更新数据 ──→ 写 MySQL ──→ 删除 Redis 里的对应 key

注意:更新时是「删除缓存」,不是「更新缓存」。 这是 Cache Aside 的精髓,很多人第一次会写错。

为什么删而不是改?

做法 问题
写库后更新缓存 1)你得把新值算出来再写进去,多写代码;2)两个请求并发更新时,缓存可能被写成旧值(时序问题);3)有些数据查出来要拼装/转换,更新缓存等于把逻辑写两遍
写库后删除缓存(推荐) 简单。删掉后,下次读自然会触发「查库 → 写回」,缓存里永远是最新的。"懒加载" 思路

前端类比:就像你改了后端数据后,调用 queryClient.invalidateQueries(key) 让缓存失效,而不是手动 queryClient.setQueryData(key, 新值)。失效(删除)比手动塞值更不容易出错。


三、demo 匿名化案例:车线标准路线的坐标点缓存

demo 的 RouteCacheService(文件:demo-basic/demo-basic-service/src/main/java/com/example/platform/basic/service/service/RouteCacheService.java)缓存的是「一条标准路线的坐标点列表」——这种数据典型的「读多写少」:路线一旦画好很少改,但每次司机导航、运单展示都要读。

它的读流程是教科书级的 Cache Aside(下面 3.2 细看);但写流程用的是「更新缓存」而不是「删除缓存」,和第二节讲的标准做法不一样——这恰恰是个真实世界的取舍点,我们在 3.3 专门拆解。

我们把它的核心代码拆开看。

3.1 三个关键常量:key 前缀、过期时间

@Service
@Slf4j
public class RouteCacheService {

    // 缓存 key 的前缀,最终 key 形如 "line:std_route:points:1001"
    private static final String CACHE_KEY_PREFIX = "line:std_route:points:";

    // 缓存过期时间:1 小时。Duration 是 Java 8 的时间段类型,比裸数字 + TimeUnit 更易读
    private static final Duration LINE_STD_CACHE_EXPIRED_TIME = Duration.ofHours(1);

    // StringRedisTemplate:Spring 提供的操作 Redis 的工具,专门处理「key 和 value 都是 String」的场景
    private StringRedisTemplate redis;

3.2 读:编排方法 + 三个小方法

demo 把读流程拆成了一个「编排方法」和三个「干活的小方法」,可读性极高,几乎就是把上面那张流程图翻译成了代码:

// 编排方法:Cache Aside 读流程的「总指挥」
// 入参 routeId:标准路线 ID,也是缓存 key 的业务标识
private List<Coord> findRoutePoints(Integer routeId) {
    // 第一步:先查缓存
    List<Coord> points = findRoutePointsFromCache(routeId);

    // 分支:缓存没命中(null 表示 Redis 里没有这个 key)
    if (points == null) {
        points = findRoutePointsFromDB(routeId); // 第二步:查数据库
        cachePoints(routeId, points);            // 第三步:写回缓存
    }

    return points;
}

逐个看三个小方法:

// 查缓存:命中返回坐标列表,没命中返回 null
@Nullable
private List<Coord> findRoutePointsFromCache(Integer routeId) {
    // 防御:routeId 为空直接返回空列表,避免拼出脏 key
    if (routeId == null) {
        return Collections.emptyList();
    }

    // 拼出完整 key,例如 "line:std_route:points:1001"
    String k = CACHE_KEY_PREFIX + routeId;
    // opsForValue() 操作「字符串类型」的值,.get(k) 相当于 redis 命令 GET k
    String v = redis.opsForValue().get(k);

    // 关键分支:v 为 null 说明缓存里没有,返回 null 让上层去查库
    if (v == null) {
        return null;
    }

    // 命中:把存储的字符串解码回坐标列表(demo 用 coordEncoder 把坐标压成短字符串再存,省内存)
    return coordEncoder.decodes(v);
}
// 查数据库:缓存没命中时的兜底数据源
private List<Coord> findRoutePointsFromDB(Integer routeId) {
    LineStdRoutePoint routePoint = routePointMapper.selectOneByStdRouteId(routeId, true);
    // 分支:库里也没有,返回空列表(注意不是 null,避免上层再判空)
    if (routePoint == null) {
        return Collections.emptyList();
    }
    return coordEncoder.decodes(routePoint.getPoints());
}
// 写回缓存:查库之后把结果塞进 Redis,并设置过期时间
private void cachePoints(Integer routeId, List<Coord> points) {
    // 防御:参数为空就不缓存,避免缓存脏数据
    if (routeId == null || points == null) {
        return;
    }

    String k = CACHE_KEY_PREFIX + routeId;
    String v = coordEncoder.encodes(points); // 坐标列表编码成字符串

    // set(key, value, 过期时间):相当于 redis 命令 SET k v EX 3600
    // 第三个参数传过期时间,1 小时后这个 key 自动消失,下次读会重新查库
    redis.opsForValue().set(k, v, LINE_STD_CACHE_EXPIRED_TIME);
}

这套拆法值得学:编排方法只关心「流程」,小方法各管「查缓存 / 查库 / 写缓存」一件事。 比把所有逻辑塞在一个方法里清晰得多。

3.3 写:demo 这里用的是「更新缓存」而非「删缓存」

讲到这你可能以为 demo 写流程就是「改库 → 删缓存」。但翻开匿名化示例代码,它走的是另一条路:新增/更新路线时,顺手把新坐标点写回缓存。createupdate 的尾巴都调了同一个 cacheRoutePoints

// 新增路线:插库 → 插轨迹点 → 写回缓存(注意:是 set,不是 delete)
private LineStdRoute create(LineStdRoute route) {
    routeMapper.insert(route);      // 写主表
    insertRoutePoint(route);        // 写轨迹点表
    cacheRoutePoints(route);        // 把新轨迹点更新进缓存
    return route;
}

// 写回缓存:把这条路线的坐标点编码后 set 进 Redis,并续上过期时间
private void cacheRoutePoints(LineStdRoute route) {
    String k = CACHE_KEY_PREFIX + route.getId();

    // 优先用 prepare() 阶段已经编码好的字符串,避免重复编码
    String v = route.getEncodedPoints();
    // 分支:还没编码过(理论上不该发生),兜底现编一次
    if (v == null) {
        v = coordEncoder.encodes(route.getPoints());
    }

    // set(key, value, 过期时间):直接写新值,相当于 SET k v EX 3600
    redis.opsForValue().set(k, v, LINE_STD_CACHE_EXPIRED_TIME);
}

update 方法结尾同样调用 cacheRoutePoints(newRoute)全文件没有一处 redis.delete——删除路线时,缓存不主动清理,靠那 1 小时过期时间自然消失。

为什么 demo 敢这么写,而第二节又说推荐「删缓存」?这正好对照第二节那张表,把取舍讲透:

第二节的「删缓存」(推荐默认) demo 这里的「更新缓存」
适用前提 通用场景,尤其是并发更新多 写入串行、且新值现成(prepare 阶段已编码好)
好处 简单、不会写入旧值 写完立刻命中缓存,省掉「下次读再查库回填」那一跳
风险 下次读有一次回源 并发更新时可能把缓存写成旧值(时序问题)

demo 这个场景里,标准路线的新增/更新都在事务内串行执行、且新坐标点是当前请求算出来的,所以「更新缓存」的并发风险很低,换来的是写完即热、读路径更短。这不是反例,而是「理解原理后按场景灵活选型」的匿名化案例。

提醒:如果你拿不准,默认按第二节的「删缓存」写,最不容易出错。「更新缓存」要用得对,前提是你能确认写入是串行的、且新值就是最终值。demo 删除路线时不清缓存、纯靠过期兜底,也是同样的权衡——能容忍最多 1 小时的旧数据。


四、给第 30 课的接口加缓存(前后对比)

第 30 课我们写了一个查询接口(这里以「根据公司 ID 查公司详情」为例,沿用第 04/05 课的 OrganizationService 风格)。下面把它从「无缓存」改造成「Cache Aside」。

4.1 加缓存前

@Service
public class OrganizationService {

    @Autowired
    private OrganizationMapper organizationMapper;

    // 加缓存前:每次都查库
    // 入参 organizationId:公司 ID
    public OrganizationVo getById(Integer organizationId) {
        Organization organization = organizationMapper.selectById(organizationId); // 每次都打 MySQL
        if (organization == null) {
            throw new BusinessException("公司不存在"); // 见第 07 课异常处理
        }
        return OrganizationVo.from(organization);
    }
}

问题:公司信息属于「读多写少」(基本不改,但到处都要展示)。每次请求都查库,QPS 一高,MySQL 就成了瓶颈。

4.2 加缓存后

@Service
@Slf4j
public class OrganizationService {

    // 缓存 key 前缀,最终形如 "basic:organization:info:1001"
    private static final String CACHE_KEY_PREFIX = "basic:organization:info:";
    // 过期时间 30 分钟:兜底,万一漏删缓存,最多脏 30 分钟
    private static final Duration CACHE_EXPIRED = Duration.ofMinutes(30);

    @Autowired
    private OrganizationMapper organizationMapper;
    @Autowired
    private StringRedisTemplate redis; // 注入 Redis 操作工具,见第 05 课 @Autowired
    @Autowired
    private ObjectMapper objectMapper; // Jackson,用来把对象和 JSON 字符串互转

    // 加缓存后:Cache Aside 读流程
    // 入参 organizationId:公司 ID,也是缓存 key 的业务标识
    public OrganizationVo getById(Integer organizationId) {
        String key = CACHE_KEY_PREFIX + organizationId;

        // 第一步:先查缓存
        String cached = redis.opsForValue().get(key);
        // 分支:缓存命中,直接反序列化返回,不查库
        if (cached != null) {
            return parse(cached);
        }

        // 第二步:缓存没命中,查库
        Organization organization = organizationMapper.selectById(organizationId);
        if (organization == null) {
            throw new BusinessException("公司不存在");
        }
        OrganizationVo vo = OrganizationVo.from(organization);

        // 第三步:写回缓存,并设过期时间
        redis.opsForValue().set(key, toJson(vo), CACHE_EXPIRED);
        return vo;
    }

    // 更新公司信息:改库 → 删缓存
    // 入参 organization:待更新的公司实体
    public void update(Organization organization) {
        organizationMapper.updateById(organization); // 先写库
        String key = CACHE_KEY_PREFIX + organization.getId();
        redis.delete(key); // 再删缓存,下次读自动加载最新值
    }

    // 把 VO 序列化成 JSON 字符串存进 Redis(Redis 只认字符串)
    private String toJson(OrganizationVo vo) {
        try {
            return objectMapper.writeValueAsString(vo);
        } catch (JsonProcessingException e) {
            throw new BusinessException("公司信息序列化失败");
        }
    }

    // 把 Redis 里的 JSON 字符串反序列化回 VO
    private OrganizationVo parse(String json) {
        try {
            return objectMapper.readValue(json, OrganizationVo.class);
        } catch (JsonProcessingException e) {
            throw new BusinessException("公司信息反序列化失败");
        }
    }
}

4.3 前后对比

维度 加缓存前 加缓存后
每次请求是否查库 仅首次/过期后查库,其余命中 Redis
典型响应耗时 几毫秒~几十毫秒(看 SQL) 命中时亚毫秒级(纯内存)
MySQL 压力 随 QPS 线性增长 大幅降低,缓存挡住绝大多数读
数据一致性 强一致(永远是库里最新) 最终一致(更新后到下次读之间,有极短窗口可能是旧值)
代码复杂度 略高(多了序列化、删缓存逻辑)

核心权衡:用「一点点数据延迟」换「大幅的性能提升」。 所以缓存适合「读多写少 + 能容忍短暂不一致」的数据(公司信息、字典、配置、路线点),不适合「强一致要求」的数据(账户余额、库存扣减——那是另一套方案)。


五、缓存 key 设计与过期时间:两个最容易踩坑的点

5.1 key 设计:用「冒号分层 + 业务前缀」

看 demo 的 key:line:std_route:points:1001,公司的:basic:organization:info:1001。规律是:

业务域  :  实体     :  维度     :  业务ID
basic   :  organization  :  info     :  1001
line    :  std_route:  points   :  1001
原则 说明 反例
加业务前缀 Redis 全集群共享,前缀避免和别的模块撞 key 直接用 1001 当 key(必撞)
用冒号 : 分层 Redis 客户端工具会按 : 折叠成树状,方便排查 _ 或不分隔,一堆 key 平铺
key 里带全业务标识 一个 key 对应一条明确数据 key 里只有半个条件,导致不同数据共用一个 key

前端类比:跟你给 react-query 设计 queryKey 一模一样——['organization', 'info', id]。带上业务维度,保证不同数据不串。

5.2 过期时间:一定要设,且不要都设成一样

// ✅ 永远带过期时间,这是兜底
redis.opsForValue().set(key, value, Duration.ofMinutes(30));

// ❌ 不设过期时间:万一某次漏删缓存,这条脏数据永远在 Redis 里出不去
redis.opsForValue().set(key, value);

为什么必须设过期时间?因为「改库 → 删缓存」万一删失败了(网络抖动、代码漏写),过期时间是最后一道防线——最坏情况也就脏 30 分钟,到点自动消失重新加载。

进阶提醒——缓存雪崩: 如果几千个 key 都设成「整 1 小时」过期,它们会在同一秒集体失效,那一瞬间所有请求全打到 MySQL,可能把库压垮。生产实践通常给过期时间加一个随机抖动

// 基础 30 分钟 + 0~5 分钟随机,错开过期时间,避免集体失效
long base = Duration.ofMinutes(30).getSeconds();
long jitter = ThreadLocalRandom.current().nextLong(0, 300); // 0~300 秒随机
redis.opsForValue().set(key, value, Duration.ofSeconds(base + jitter));

六、还要知道的两个坑(生产必遇)

问题 是什么 简单应对
缓存穿透 查一个库里根本不存在的 id,缓存永远不命中,每次都打库(常被恶意刷) 库里没查到时,也缓存一个空值(设较短过期,如 1 分钟),下次直接挡掉
缓存击穿 某个热点 key 突然过期,瞬间大量请求同时查库 查库前加分布式锁,只放一个请求去查库回填,其余等待

这一课先建立 Cache Aside 的正确心智,这两个进阶问题知道名字和大致思路即可,真正落地时再深入。demo 里也确实用到了分布式锁(LockService),属于后面的话题。


七、本课小结

  • 缓存的本质:把「读多写少、查一次贵」的数据放进 Redis(可理解为全后端共享的、带过期时间的超大 Map<String,String>),下次直接读,绕过 MySQL。
  • Cache Aside 读流程:先查缓存 → 命中返回 → 没命中查库 → 写回缓存(带过期时间)。心智模型同前端 react-query
  • Cache Aside 写流程改库 → 删缓存(删而不是改,顺序不能反)。类比前端的 invalidateQueries
  • demo 示例实践RouteCacheService读流程是标准范本——拆成「编排方法 findRoutePoints + 查缓存/查库/写缓存三个小方法」,key 前缀 line:std_route:points:,过期 1 小时。它的写流程用的是「更新缓存」而非「删缓存」(删除路线则纯靠过期兜底),是「按场景灵活选型」的工程取舍,不是模板。
  • key 设计:业务前缀 + 冒号分层 + 全业务标识,例如 basic:organization:info:1001
  • 过期时间:永远要设(兜底防漏删),且加随机抖动避免缓存雪崩。
  • 性能权衡:用「极短的数据延迟」换「大幅性能提升」,适合能容忍最终一致的数据,不适合余额/库存这类强一致场景。
  • 进阶坑:缓存穿透(缓存空值)、缓存击穿(加锁回填),先记住名字和思路。

下一课预告:第 32 课《实战——加缓存后的数据一致性陷阱》。这一课我们埋了好几个伏笔(先删还是后删、删失败怎么办、并发更新),下一课就来系统拆解「缓存与数据库如何保持一致」,并看 demo 是怎么用分布式锁兜住这些坑的。

八、总结

  • 先回顾:为什么要缓存:| React.useMemo / useCallback | 组件内存 | 避免重复计算 |
  • Cache Aside 模式:业界最常用的缓存套路:注意:更新时是「删除缓存」,不是「更新缓存」。
  • demo 匿名化案例:车线标准路线的坐标点缓存:demo 的 RouteCacheService(文件:demo-basic/demo-basic-service/src/main/java/com/example/platform/basic/service/service/RouteCacheService.java)缓存的是「一条标准路线的坐标点列表」——这种数据典型的「读多写少」:路线一旦画好很少
  • 给第 30 课的接口加缓存(前后对比):| 每次请求是否查库 | 是 | 仅首次/过期后查库,其余命中 Redis |
  • 缓存 key 设计与过期时间:两个最容易踩坑的点:| 加业务前缀 | Redis 全集群共享,前缀避免和别的模块撞 key | 直接用 1001 当 key(必撞) |
  • 还要知道的两个坑(生产必遇):| 问题 | 是什么 | 简单应对 |

学完自测

选择所有正确答案;提交后逐项核对判断依据。

1在“实战——给现有接口加 Redis 缓存”中,需要同时满足“先回顾:为什么要缓存”与“Cache Aside 模式:业界最常用的缓存套路”。给定正文约束“一个跑在服务器上的、超大号的、所有 Java 进程共享的 Map,数据存在内存里所以读写极快(微秒级),而且支持给每个 key 设过期时间。”,哪些判断保持了原有处理机制?多选
2“实战——给现有接口加 Redis 缓存”出现偏差:“在“实战——给现有接口加 Redis 缓存 / 读流程(查询时)”中,即使不满足“这跟你用 react-query 的心智模型几乎一样”,结果与副作用仍会保持不变。”已成为实际行为。围绕“读流程(查询时)”与“写流程(更新时)—— 关键是「删缓存」而不是「改缓存」”,哪些判断能定位被改变的职责或边界?多选
3评审“实战——给现有接口加 Redis 缓存”方案时,验收条件包含“但写流程用的是「更新缓存」而不是「删除缓存」,和第二节讲的标准做法不一样——这恰恰是个真实世界的取舍点,我们在 3.3 专门拆解。”。关于“demo 匿名化案例:车线标准路线的坐标点缓存”与“读:编排方法 + 三个小方法”的哪些决策符合正文机制?多选