上篇:Java 后端详解(四):分页与搜索
本篇以文章模块为例,讲清:Redis 如何接入 Spring Boot、Spring Cache 注解怎么用、文章列表与详情如何缓存、何时失效。
一、本篇要解决的问题
上一篇做完分页与搜索后,每次访问文章列表或详情都会查 MySQL。热门文章被反复打开、首页频繁刷新时,数据库压力会明显上升。
| 场景 | 无缓存时 | 有 Redis 缓存后 |
|---|---|---|
| 首页拉「最新 5 篇」 | 每次 SELECT ... LIMIT 5 | 命中缓存,直接读内存 |
| 文章详情页被多人浏览 | 同一 id 重复查库 | 第一次查库,后续读 Redis |
| 同一页码反复翻页 | 重复执行分页 SQL | 按 page:size:keyword 缓存结果 |
改造目标:读多写少的接口走缓存,写操作后自动清掉旧数据,保证用户看到的是最新内容。
二、整体架构:请求经过哪几层
浏览器
│
▼
ArticleController
│
▼
ArticleService ←── Spring AOP 在这里拦截 @Cacheable / @CacheEvict
│
├── 缓存命中 ──→ Redis(articles / articlePages)
│
└── 缓存未命中 ──→ ArticleRepository ──→ MySQL
本项目采用 Spring Cache 抽象 + Redis 实现:
- 业务代码只写
@Cacheable、@CacheEvict,不直接拼 Redis 命令 - 底层由
RedisCacheManager把缓存存进 Redis - 同时提供
RedisTemplate,方便以后做限流、分布式锁等扩展
三、第一步:引入依赖
在 backend/pom.xml 中增加两个 starter:
<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 | Redis 连接、序列化、RedisTemplate |
spring-boot-starter-cache | @Cacheable 等缓存注解与 AOP 支持 |
Spring Boot 3.x 默认使用 Lettuce 客户端连接 Redis,无需额外配置驱动。
四、第二步:application.yml 连接 Redis
spring:
data:
redis:
host: localhost
port: 6379
timeout: 3000ms
cache:
type: redis
redis:
time-to-live: 1800000 # 毫秒,30 分钟
| 配置项 | 含义 |
|---|---|
spring.data.redis.host | Redis 地址,本地开发一般为 localhost |
spring.data.redis.port | 端口,默认 6379 |
spring.data.redis.timeout | 连接超时 |
spring.cache.type: redis | 告诉 Spring Cache 用 Redis 而不是本地 Map |
spring.cache.redis.time-to-live | 全局默认过期时间(与代码里 RedisCacheConfiguration 可叠加理解,最终以 CacheManager Bean 为准) |
注意:应用启动前 Redis 必须已运行,否则连接失败会导致启动报错。
本地启动 Redis 的常见方式:
| 环境 | 命令 / 方式 |
|---|---|
| Linux / WSL | redis-server |
| Docker(本地) | docker run -d -p 6379:6379 redis:7 |
| Windows | 可使用 Memurai 等 Redis 兼容产品 |
五、第三步:RedisConfig 配置类
文件:backend/src/main/java/com/blog/config/RedisConfig.java
这个类做三件事,先用一张图记住:
application.yml RedisConfig ArticleService
(host / port) │ │
│ │ │
▼ ▼ ▼
RedisConnectionFactory ──► cacheManager ◄──── @Cacheable / @CacheEvict ← 文章缓存走这条
│ ▲
│ redisObjectMapper(JSON 序列化规则)
│
└──► redisTemplate(手动操作 Redis,本项目暂未使用)
| Bean | 干什么 | 本项目用没用 |
|---|---|---|
redisObjectMapper | 规定对象怎么转成 JSON 存进 Redis | 间接使用 |
cacheManager | 让 @Cacheable 注解能读写 Redis | 在用 |
redisTemplate | 代码里手写 get / set / increment | 暂未使用,预留扩展 |
5.1 类声明
@Configuration
@EnableCaching
public class RedisConfig {
@Configuration:配置类,里面的@Bean方法会注册到 Spring 容器@EnableCaching:必须加,否则ArticleService上的@Cacheable不会生效
5.2 redisObjectMapper():对象怎么存进 Redis
@Bean
public ObjectMapper redisObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY);
return mapper;
}
Redis 里不能存 Java 对象,只能存字符串。这个 Bean 负责 Java 对象 ↔ JSON 的转换,主要解决两个问题:
① LocalDateTime 怎么序列化
Article 有 createdAt 字段。加上 JavaTimeModule 后,时间会存成 "2026-06-30T10:00:00",而不是报错或变成数组。
② 读回来的时候怎么知道是 Article
加上 activateDefaultTyping 后,JSON 里会多一个 @class 字段:
{
"@class": "com.blog.entity.Article",
"id": 1,
"title": "Spring Boot 入门",
"createdAt": "2026-06-30T10:00:00"
}
没有这个字段,读回来会变成 LinkedHashMap,article.getTitle() 就会出问题。
5.3 redisTemplate():手动操作 Redis(预留)
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory,
ObjectMapper redisObjectMapper) {
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(redisObjectMapper);
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer()); // key → 可读字符串
template.setHashKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer); // value → JSON
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
RedisTemplate 是底层 API,在代码里直接写 Redis 命令。上面几行配置的含义很简单:
- key 用字符串:Redis 里看到
view:123,方便调试 - value 用 JSON:和 5.2 的
redisObjectMapper规则一致
适合计数、限流等场景,例如:
redisTemplate.opsForValue().increment("article:view:" + id);
当前项目的文章缓存不走它,走的是下面的 cacheManager + 注解。这里提前配好,以后扩展时可以直接 @Autowired 使用。
5.4 cacheManager():给 @Cacheable 用(核心)
@Bean
public RedisCacheManager cacheManager(
RedisConnectionFactory connectionFactory,
ObjectMapper redisObjectMapper) {
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(redisObjectMapper);
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)) // 30 分钟过期
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(serializer));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
@Cacheable 本身不操作 Redis,真正干活的是这个 cacheManager。它规定了三件事:
| 配置 | 效果 |
|---|---|
entryTtl(30分钟) | 缓存自动过期,防止脏数据一直留着 |
| key 用字符串 | Redis 里看到 articles::1,方便调试 |
| value 用 JSON | 和 5.2 的 redisObjectMapper 规则一致 |
和文章接口怎么配合?
| 操作 | 注解 | Redis 做什么 |
|---|---|---|
getById 读详情 | @Cacheable | 先查 articles::id,没有则查库并写入 |
queryArticles 读列表 | @Cacheable | 先查 articlePages::页码:条数:关键词 |
create 新建 | @CacheEvict | 清空所有列表缓存(新文章还没 id,无详情缓存) |
update / delete | @CacheEvict | 删该文章详情 + 清空所有列表缓存 |
读走 cacheManager 查/写缓存,写只清缓存不写缓存——新文章要等第一次 getById 才会被缓存进去。
5.5 小结:两个出口,别搞混
读文章(getById / queryArticles)
→ @Cacheable → cacheManager → Redis 查/写
写文章(create / update / delete)
→ @CacheEvict → cacheManager → Redis 删缓存
→ articleRepository → MySQL 写库
以后做浏览量、限流
→ redisTemplate → Redis 直接操作
记住一句:本项目文章缓存只用 cacheManager,redisTemplate 是备用。
六、第四步:在 ArticleService 上加缓存注解
文件:backend/src/main/java/com/blog/service/ArticleService.java
1. 查询:用 @Cacheable 写入缓存
文章详情——按 id 缓存:
@Cacheable(value = "articles", key = "#id")
public Article getById(Long id) {
return articleRepository.findById(id)
.orElseThrow(() -> new RuntimeException("文章不存在"));
}
文章分页列表——按页码、每页条数、关键词组合缓存:
@Cacheable(
value = "articlePages",
key = "#page + ':' + #size + ':' + (#keyword != null ? #keyword.trim() : '')")
public ArticlePageResult queryArticles(int page, int size, String keyword) {
// ... 分页与搜索逻辑不变
}
| 注解属性 | 含义 |
|---|---|
value | 缓存「命名空间」,对应 Redis 里的 cache name |
key | 缓存键,支持 SpEL 表达式,#id 表示方法参数 |
Redis 中实际 key 形如:
articles::1
articles::42
articlePages::0:10:
articlePages::0:10:Spring
articlePages::1:10:Vue
格式为:{cacheName}::{key},由 Spring Cache 自动拼接。
2. 写操作:用 @CacheEvict 清除缓存
新建文章——列表缓存全部失效(新文章会出现在列表里):
@CacheEvict(value = "articlePages", allEntries = true)
@Transactional
public Article create(ArticleRequest request) {
// ...
}
更新 / 删除——同时清掉该文章详情 + 所有列表页缓存:
@Caching(evict = {
@CacheEvict(value = "articles", key = "#id"),
@CacheEvict(value = "articlePages", allEntries = true)
})
@Transactional
public Article update(Long id, ArticleRequest request) {
// ...
}
@Caching 用于在一个方法上组合多个缓存操作。
3. 缓存策略一览
| 方法 | 注解 | 行为 |
|---|---|---|
queryArticles | @Cacheable | 读:先查 Redis,没有再查库并写入 |
getById | @Cacheable | 读:同上 |
create | @CacheEvict(articlePages) | 写:清空所有列表缓存 |
update | @CacheEvict(articles + articlePages) | 写:清该 id 详情 + 所有列表 |
delete | @CacheEvict(articles + articlePages) | 写:同上 |
为什么列表用 allEntries = true 而不是按 key 精确删除?
- 列表 key 组合多(页码 × 每页条数 × 关键词),逐一维护成本高
- 文章变更频率通常低于查询频率,整表清空列表缓存更简单可靠
七、一次请求的完整流程
场景 A:第一次访问 GET /api/articles?page=0&size=10
1. Controller 调用 articleService.queryArticles(0, 10, null)
2. AOP 根据 key "0:10:" 查 Redis → 未命中
3. 执行方法体:articleRepository.findAll(pageable)
4. 将 ArticlePageResult 写入 Redis(articlePages::0:10:)
5. 返回给前端
场景 B:10 秒内再次请求相同 URL
1. Controller 再次调用 queryArticles(0, 10, null)
2. AOP 查 Redis → 命中
3. 直接反序列化返回,不执行方法体,控制台不会出现 SQL
场景 C:用户修改文章后
1. PUT /api/articles/5
2. update() 执行前先清 articles::5 和全部 articlePages::*
3. 保存到 MySQL
4. 下次 GET 会重新查库并写入新缓存
八、三个核心注解对比
| 注解 | 触发时机 | 典型用途 |
|---|---|---|
@Cacheable | 方法成功返回后写入缓存;下次相同 key 直接返回缓存 | 查询详情、列表 |
@CacheEvict | 方法执行时删除指定缓存 | 增删改后保证数据一致 |
@Caching | 组合多个 @Cacheable / @CacheEvict | 一次写操作要清多处缓存 |
补充:@CachePut 每次都会执行方法并更新缓存(本项目未使用,更新场景用「先 evict 再查库」更直观)。
九、如何验证缓存已生效
1. 看 SQL 日志
application.yml 中 spring.jpa.show-sql: true 时:
- 第一次请求
GET /api/articles→ 控制台打印SELECT ... - 立即再请求一次 → 不应再出现相同 SELECT
2. 用 redis-cli 看 key
redis-cli keys "*article*"
应能看到类似:
1) "articlePages::0:10:"
2) "articles::3"
查看某条缓存内容:
redis-cli get "articles::3"
3. 改文章后再看
执行 PUT /api/articles/3 后:
redis-cli keys "*article*"
articles::3 和 articlePages::* 应被清除;再次 GET 会重新生成。
十、常见坑与注意事项
1. 同类内部调用不走缓存
update() 内部调用了 getById(id),这是 this.getById(),不经过 Spring 代理,不会触发 @Cacheable。
对本项目这是合理行为:写操作前需要从数据库拿最新实体做权限校验,不应读旧缓存。
若要让内部调用也走缓存,需通过注入自身代理或拆到另一个 Service——一般不必。
2. Redis 未启动
启动报错类似 Unable to connect to Redis。先确认 redis-server 或容器在 6379 端口监听。
3. 缓存与事务
@CacheEvict 默认在方法执行后清缓存。写方法已加 @Transactional,数据库提交成功后再失效缓存,避免回滚后缓存却被清掉的极端情况(Spring 默认 evict 时机可配置,初学保持默认即可)。
4. 缓存的是 Java 对象,不是 SQL 结果
ArticlePageResult 和 Article 会被整体序列化进 Redis。实体字段变更(加字段、改类型)后,旧缓存 JSON 可能反序列化失败,开发期可 redis-cli flushall 清空。
5. 安全与部署
- 生产环境为 Redis 设置密码,并在
spring.data.redis.password中配置 - 不要把 Redis 暴露到公网;仅应用内网访问
- 敏感数据(如用户密码)不应放入缓存明文
十一、改造前 vs 改造后
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 文章列表 | 每次请求都查 MySQL | 相同 page/size/keyword 30 分钟内走 Redis |
| 文章详情 | 每次 findById | 相同 id 命中缓存 |
| 新建 / 改 / 删 | 只写数据库 | 写库 + 自动失效相关缓存 |
| 依赖 | MySQL | MySQL + Redis |
| 代码侵入 | — | Service 方法上几个注解,Controller 无变化 |
对前端完全透明:接口 URL、请求参数、响应 JSON 格式不变,只是响应变快。
十二、可扩展方向
| 需求 | 思路 |
|---|---|
| 评论列表缓存 | 在 CommentService.listByArticleId 上加 @Cacheable,key 用 articleId |
| 按缓存名设置不同 TTL | RedisCacheManager 的 .withCacheConfiguration("articles", config) |
| 热点文章浏览量 | RedisTemplate.opsForValue().increment("view:" + id) |
| 接口限流 | Redis + 自定义注解或 Bucket4j |
| JWT 登出黑名单 | 将 token 存入 Redis,过期时间与 JWT 一致 |
| 多实例部署 | 多台 Spring Boot 共享同一 Redis,缓存天然一致 |
十三、本篇小结
Redis 在本项目中的定位,是 MySQL 前面的读缓存层,通过 Spring Cache 以声明式方式接入:
| 步骤 | 文件 | 做了什么 |
|---|---|---|
| 加依赖 | pom.xml | data-redis + cache |
| 配连接 | application.yml | host、port、TTL |
| 配序列化 | RedisConfig.java | RedisCacheManager + RedisTemplate |
| 加注解 | ArticleService.java | 读缓存、写失效 |
记住这条主线:
@Cacheable 查询 → Redis 命中则返回 / 未命中则查库并写入
@CacheEvict 写入 → 删 Redis 中过期数据 → 下次查询自动重建
与分页篇串联起来,完整读路径为:
@RequestParam → @Cacheable → (Redis 或 Repository) → ArticlePageResult → ApiResponse
可在此基础上扩展 评论缓存、Redis 计数 或 JWT 黑名单登出。