来自苍穹外卖项目
读请求负责“加缓存”,写请求负责“删缓存”,一致性靠“写后失效 + 读时回填”。
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-out 的 sky-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(旁路缓存)是最常用模式,流程非常清晰:
- 读请求:先查 Redis,未命中再查 DB,并把结果回填 Redis;
- 写请求:先写 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_1、dish_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();
}
delete、startOrStop 也会调用 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,但在真实线上环境,建议至少补三件事:
-
给缓存设置 TTL(过期时间)
现在set(key, list)没有 TTL,异常情况下可能长期保留旧值。建议改为带过期时间写入(如 30 分钟),并可加少量随机值,避免同一时刻大量 key 一起过期。 -
避免缓存穿透(查询不存在数据反复打 DB)
对明确不存在的数据,可短暂缓存空值(短 TTL),防止恶意或异常请求持续穿透到数据库。 -
控制热点 key 的瞬时并发(防击穿)
热点 key 失效瞬间可能大量回源。可以在应用层为重建缓存增加互斥控制(如分布式锁/本地锁),保证同一时刻只有少量请求回源构建。
先保证一致性(写后删),再提升稳定性(TTL + 防穿透 + 防击穿)。
4. 总结
- Redis 解决的是高频读场景下的性能和数据库压力问题;
- 基本用法是
SET/GET/DEL/EXPIRE,核心模式是 Cache Aside; - 在 sky-take-out 里,读请求先查
dish_{categoryId},未命中查库并写回;写请求再清理dish_* 或dish_{categoryId}; - 真正要掌握的是“数据库 + 缓存双存储的一致性思维”。
附录:相关源码路径
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