Redis 缓存与失效策略:从入门到业务场景应用

7 阅读6分钟

来自苍穹外卖项目

读请求负责“加缓存”,写请求负责“删缓存”,一致性靠“写后失效 + 读时回填”。

1. Redis 有什么用?为什么要引入 Redis?

1.1 结论速记

在外卖系统中,菜单、分类、店铺状态这类数据是高频读,如果每次都查 MySQL,会带来两个问题:

  • 数据库压力大,QPS 上来后容易抖动;
  • 接口响应慢,用户体验差。

Redis 是内存数据库,读写非常快。引入 Redis 的核心目标是:

  • 加速读请求(热点数据走缓存);
  • 削峰减压(减少 MySQL 直连查询量);
  • 提升系统稳定性(数据库压力可控)。

1.2 不引入 Redis 会怎样?

flowchart LR
    A[用户查询菜品列表] --> B[每次都查 MySQL]
    B --> C[并发升高]
    C --> D[数据库压力上升]
    D --> E[响应变慢/超时]

1.3 引入 Redis 后的读路径

flowchart LR
    A[用户查询菜品列表] --> B{Redis 命中?}
    B -->|命中| C[直接返回]
    B -->|未命中| D[查询 MySQL]
    D --> E[写回 Redis]
    E --> F[返回结果]

但要记住:引入 Redis 后,数据变成了“数据库一份 + 缓存一份”,两份数据一致性要靠我们主动维护。


2. Redis 基本用法

这里只讲项目里最常见的基础操作。

2.1 使用 Redis 需要导入什么依赖?

在 Spring Boot 项目里,最核心依赖是 spring-boot-starter-data-redis
sky-take-outsky-server/pom.xml 已经引入:

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

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

说明:

  • spring-boot-starter-data-redis:提供 RedisTemplate、序列化、连接工厂等能力;
  • spring-boot-starter-cache:提供 Spring Cache 抽象(如 @Cacheable);
  • 默认 Redis 客户端是 Lettuce,通常不需要额外再引 jedis

2.2 配置是怎么接进来的?

application.yml 定义 Redis 参数占位:

  redis:
    host: ${sky.redis.host}
    port: ${sky.redis.port}
    password: ${sky.redis.password}
    database: ${sky.redis.database}

application-dev.yml 提供本地开发值:

  redis:
    host: localhost
    port: 6379
    password: 123456
    database: 10

2.3 常见数据操作

操作命令示例说明
写入SET dish_1 "...json..."写入缓存
读取GET dish_1命中则直接返回
删除DEL dish_1主动失效缓存
过期时间EXPIRE dish_1 300设置 TTL,避免永久脏数据

2.4 项目里最重要的缓存思路:Cache Aside

Cache Aside(旁路缓存)是最常用模式,流程非常清晰:

  1. 读请求:先查 Redis,未命中再查 DB,并把结果回填 Redis;
  2. 写请求:先写 DB,成功后删除相关缓存 key。
sequenceDiagram
    participant Client as 客户端
    participant App as 应用服务
    participant Redis as Redis
    participant DB as MySQL

    Client->>App: 查询数据
    App->>Redis: GET key
    alt 命中
        Redis-->>App: value
        App-->>Client: 返回缓存
    else 未命中
        Redis-->>App: nil
        App->>DB: SELECT
        DB-->>App: 最新数据
        App->>Redis: SET key value
        App-->>Client: 返回结果
    end

2.5 为什么“写后删缓存”而不是“写后更新缓存”?

  • 删除缓存逻辑简单,不容易漏字段;
  • 下次读,自然回填,数据模型更统一;
  • 在大多数业务下,这是实现成本和一致性的平衡点。

3. Redis 在苍穹外卖项目中的使用(结合代码)

先看 C 端查询如何回填缓存,再看管理端写操作如何清缓存。

3.1 读请求如何“添加缓存”?

很多同学会误以为“缓存是写接口加进去的”,但在 Cache Aside 模式下,常见做法是:读接口负责回填缓存,写接口负责删除缓存

user 端菜品查询接口里,已经完整实现了“先查缓存 -> 未命中查库 -> 写回缓存”:

    @GetMapping("/list")
    @ApiOperation("根据分类id查询菜品")
    public Result<List<DishVO>> list(Long categoryId) {

        //构造redis中的key,规则:dish_分类id
        String key = "dish_" + categoryId;

        //查询redis中是否存在菜品数据
        List<DishVO> list = (List<DishVO>) redisTemplate.opsForValue().get(key);
        if(list != null && list.size() > 0){
            //如果存在,直接返回,无须查询数据库
            return Result.success(list);
        }

        Dish dish = new Dish();
        dish.setCategoryId(categoryId);
        dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品

        //如果不存在,查询数据库,将查询到的数据放入redis中
        list = dishService.listWithFlavor(dish);
        redisTemplate.opsForValue().set(key, list);

        return Result.success(list);
    }

这段代码就是“添加 Redis 缓存”的核心来源:不是主动预热,而是查询时懒加载回填

3.1 应用场景

业务操作缓存处理策略
新增菜品删除该分类 key(dish_{categoryId}
修改菜品删除 dish_*
删除菜品删除 dish_*
起售/停售删除 dish_*

这里体现的是一个工程取舍:dish_* 会“多删一些”,但实现简单且不容易漏删,能保证读到最新数据。

dish_* 里的 * 是通配符,表示匹配所有以 dish_ 开头的 key(如 dish_1dish_2)。宁可多删一点缓存、让后续请求回源重建,也不要漏删导致用户一直读到旧数据。

3.2 代码一:新增菜品后按分类清缓存

    @PostMapping
    @ApiOperation("新增菜品")
    public Result save(@RequestBody DishDTO dishDTO) {
        log.info("新增菜品:{}", dishDTO);
        dishService.saveWithFlavor(dishDTO);

        //清理缓存数据
        String key = "dish_" + dishDTO.getCategoryId();
        cleanCache(key);
        return Result.success();
    }

3.3 代码二:修改后清理 dish_*

    @PutMapping
    @ApiOperation("修改菜品")
    public Result update(@RequestBody DishDTO dishDTO) {
        log.info("修改菜品:{}", dishDTO);
        dishService.updateWithFlavor(dishDTO);

        //将所有的菜品缓存数据清理掉,所有以dish_开头的key
        cleanCache("dish_*");

        return Result.success();
    }

deletestartOrStop 也会调用 cleanCache("dish_*"),思路一致。

3.4 代码三:清缓存实现

    /**
     * 清理缓存数据
     * @param pattern
     */
    private void cleanCache(String pattern){
        Set keys = redisTemplate.keys(pattern);
        redisTemplate.delete(keys);
    }

如果是生产环境,建议把 keys(pattern) 换成 scan 分批删除。

  • KEYS 会一次性扫描全部 key,数据量大时可能阻塞 Redis;
  • SCAN 是增量迭代,更适合线上高并发场景;

优化后的代码如下

import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.RedisCallback;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Set;

private void cleanCacheByScan(String pattern) {
    Set<String> keys = redisTemplate.execute((RedisCallback<Set<String>>) connection -> {
        Set<String> result = new HashSet<>();
        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build();
        try (Cursor<byte[]> cursor = connection.scan(options)) {
            while (cursor.hasNext()) {
                result.add(new String(cursor.next(), StandardCharsets.UTF_8));
            }
        }
        return result;
    });

    if (keys != null && !keys.isEmpty()) {
        redisTemplate.delete(keys);
    }
}

本项目 Redis 的核心价值:不追求“每次都精准更新缓存”,而是通过“写后失效 + 读时回填”保证数据逐步一致且实现简单。

3.7 生产环境建议

当前示例代码已经能跑通 Cache Aside,但在真实线上环境,建议至少补三件事:

  1. 给缓存设置 TTL(过期时间)
    现在 set(key, list) 没有 TTL,异常情况下可能长期保留旧值。建议改为带过期时间写入(如 30 分钟),并可加少量随机值,避免同一时刻大量 key 一起过期。

  2. 避免缓存穿透(查询不存在数据反复打 DB)
    对明确不存在的数据,可短暂缓存空值(短 TTL),防止恶意或异常请求持续穿透到数据库。

  3. 控制热点 key 的瞬时并发(防击穿)
    热点 key 失效瞬间可能大量回源。可以在应用层为重建缓存增加互斥控制(如分布式锁/本地锁),保证同一时刻只有少量请求回源构建。

先保证一致性(写后删),再提升稳定性(TTL + 防穿透 + 防击穿)。


4. 总结

  1. Redis 解决的是高频读场景下的性能和数据库压力问题;
  2. 基本用法是 SET/GET/DEL/EXPIRE,核心模式是 Cache Aside;
  3. 在 sky-take-out 里,读请求先查 dish_{categoryId},未命中查库并写回;写请求再清理 dish_* 或 dish_{categoryId}
  4. 真正要掌握的是“数据库 + 缓存双存储的一致性思维”。

附录:相关源码路径

sky-server/src/main/java/com/sky/controller/admin/DishController.java
sky-server/src/main/java/com/sky/controller/user/DishController.java
sky-server/src/main/resources/application.yml
sky-server/src/main/resources/application-dev.yml

参考

苍穹外卖www.bilibili.com/video/BV1TP…

deekseek-v4