Spring Boot 4 旅游主题实战教程 阶段四:缓存与底层进阶
12 - Redis 入门:给旅游项目装上"内存引擎"
本章目标:
- 理解 Redis 的定位:内存数据库,10万+ QPS,弥补 MySQL 高频读短板
- 掌握
StringRedisTemplate操作五大基本类型- 旅游场景实战:详情缓存、点赞计数、动态流、收藏去重、热门排行榜
- 养成 key 前缀规范(
ch12:)与 TTL 意识
12.1 上章回顾与本章动机
第11章解决了数据一致性。现在换个角度想性能:
景点首页每次刷新都要 SELECT * FROM t_ch09_attraction------
数据库连接池 5 个,100 人同时刷就排队了。
而这类读多写少的数据,放内存里读才是正解:
| 对比 | MySQL(磁盘B+树) | Redis(内存跳表/哈希) |
|---|---|---|
| 读 QPS | ~2000 | 100,000+ |
| 延迟 | ms 级 | µs 级 |
| 数据容量 | TB | GB(受内存限制) |
| 定位 | 持久化主存储 | 缓存 / 计数 / 排行 |
理论知识 ------ Redis 的五种基本类型
不是五种"数据",是五种"值的组织结构":
String(字符串)/ Hash(字段表)/ List(双端链表)/ Set(无序去重)/ ZSet(带分数的有序集合)。
每种类型都有专属命令族,Spring 里对应
opsForXxx()入口。
12.2 环境准备
pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter12-redis</artifactId>
<dependencies>
<!-- Web 场景 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!--
Redis 场景 starter:
引入 Lettuce 客户端(默认,基于 Netty)+ RedisAutoConfiguration。
自动配置好 RedisConnectionFactory 与 StringRedisTemplate。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 连接池支持(Lettuce 需要 commons-pool2 才能启用 pooling) -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
12.3 跟我做:逐文件编写
12.3.1 service/RedisDemoService.java ------ 五大类型主战场
java
package com.lihaozhe.chapter12.service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
import java.util.Set;
/**
* Redis 演示服务(第12章主角:StringRedisTemplate 五大操作)
*
* <p>理论知识 ------ 为什么用 StringRedisTemplate 而不是 RedisTemplate?
* RedisTemplate<Object,Object> 默认用 JDK 序列化,存进 Redis 的 key
* 会带一串乱码前缀(\xac\xed...),redis-cli 里没法看。
* StringRedisTemplate 的 key/value 都是 String,配合手动 JSON 化对象,
* 是 99% 业务场景的正确选择。
*
* <p>理论知识 ------ Redis 五大基本类型与旅游场景映射:
* <ul>
* <li>String:景点详情缓存、验证码、计数器(点赞数)</li>
* <li>Hash :对象的字段级读写(景点的 name/price 分开存取)</li>
* <li>List :最新动态流(LPUSH + LRANGE 取最近 N 条)</li>
* <li>Set :用户收藏去重、共同关注(SINTER 交集)</li>
* <li>ZSet :排行榜(分数自动排序,本章压轴)</li>
* </ul>
*/
@Service
public class RedisDemoService {
private final StringRedisTemplate redis;
public RedisDemoService(StringRedisTemplate redis) {
this.redis = redis;
}
// ============ 1. String:缓存 + 过期时间 ============
/** 写缓存:SET key value EX 秒(等价于 SETEX) */
public void cacheAttraction(long id, String json) {
// opsForValue() 返回 String 类型操作的入口
redis.opsForValue().set("ch12:attraction:" + id, json, Duration.ofMinutes(10));
}
/** 读缓存 */
public String getCachedAttraction(long id) {
return redis.opsForValue().get("ch12:attraction:" + id);
}
/**
* 计数器(String 的原子自增)------ 点赞场景。
* INCR 是原子操作,并发下也不会少加,比"读出来+1写回去"安全。
*/
public long like(long attractionId) {
return redis.opsForValue().increment("ch12:likes:" + attractionId);
}
public long getLikes(long attractionId) {
String v = redis.opsForValue().get("ch12:likes:" + attractionId);
return v == null ? 0 : Long.parseLong(v);
}
// ============ 2. Hash:对象字段级存取 ============
/** 整体写入 Hash:HSET 多个 field-value */
public void saveProfile(String userId, String nickname, String city) {
redis.<String, String>opsForHash().putAll("ch12:profile:" + userId,
java.util.Map.of("nickname", nickname, "city", city));
}
/** 只改一个字段:HSET(对比 String 缓存需要整存整取) */
public void updateCity(String userId, String city) {
redis.<String, String>opsForHash().put("ch12:profile:" + userId, "city", city);
}
/** 读全部字段:HGETALL → Map */
public java.util.Map<String, String> getProfile(String userId) {
return redis.<String, String>opsForHash().entries("ch12:profile:" + userId);
}
// ============ 3. List:最新动态流 ============
/** 头插法:新动态放最前面(LPUSH),并只保留最近 50 条(LTRIM 截断) */
public void postMoment(String city, String content) {
String key = "ch12:moments:" + city;
redis.opsForList().leftPush(key, content);
redis.opsForList().trim(key, 0, 49); // 只留索引0~49的元素
}
/** 读最新 N 条:LRANGE 0 -1 表示从头到尾 */
public List<String> recentMoments(String city) {
return redis.opsForList().range("ch12:moments:" + city, 0, -1);
}
// ============ 4. Set:收藏去重 + 共同收藏 ============
/** 收藏:SADD 自动去重,重复收藏返回 false */
public boolean favorite(String userId, long attractionId) {
// add 返回 Long:新增的数量。0 = 已存在(去重),null 视为失败
Long added = redis.opsForSet().add("ch12:fav:" + userId,
String.valueOf(attractionId));
return added != null && added > 0;
}
/** 共同收藏:SINTER 求两个 Set 的交集 */
public Set<String> commonFavorites(String userA, String userB) {
return redis.opsForSet().intersect(
"ch12:fav:" + userA, "ch12:fav:" + userB);
}
// ============ 5. ZSet:排行榜(本章压轴) ============
/**
* 点赞并同步到排行榜:ZINCRBY 给成员加分。
* ZSet 按 score 自动排序------不用自己 sort!
*/
public double likeAndRank(long attractionId) {
like(attractionId); // 明细计数照旧
redis.opsForZSet().incrementScore( // 排行榜加分
"ch12:rank:likes", "attraction:" + attractionId, 1);
return redis.opsForZSet().score("ch12:rank:likes",
"attraction:" + attractionId);
}
/**
* 热门榜 Top N:ZREVRANGE 分数从高到低取前 N 名。
* WithScores 让返回值带上分数;遍历用 ZSetOperations.TypedTuple。
*/
public List<String> topN(int n) {
Set<ZSetOperations.TypedTuple<String>> tuples =
redis.opsForZSet().reverseRangeWithScores("ch12:rank:likes", 0, n - 1L);
if (tuples == null) {
return List.of();
}
return tuples.stream()
.map(t -> t.getValue() + " (赞:" + (t.getScore() == null ? 0 : t.getScore().longValue()) + ")")
.toList();
}
}
12.3.2 controller/RedisDemoController.java
java
package com.lihaozhe.chapter12.controller;
import com.lihaozhe.chapter12.service.RedisDemoService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Redis 演示接口(第12章)
*/
@RestController
@RequestMapping("/redis")
public class RedisDemoController {
private final RedisDemoService service;
public RedisDemoController(RedisDemoService service) {
this.service = service;
}
// ---------- String ----------
/** 写缓存:POST /redis/cache/1?json={"name":"莫日格勒河"} */
@PostMapping("/cache/{id}")
public String cache(@PathVariable long id, @RequestParam String json) {
service.cacheAttraction(id, json);
return "cached (TTL 10min)";
}
@GetMapping("/cache/{id}")
public String cache(@PathVariable long id) {
String v = service.getCachedAttraction(id);
return v == null ? "(缓存未命中)" : v;
}
/** 点赞计数(String INCR) */
@PostMapping("/like/{attractionId}")
public long like(@PathVariable long attractionId) {
return service.like(attractionId);
}
@GetMapping("/like/{attractionId}")
public long getLike(@PathVariable long attractionId) {
return service.getLikes(attractionId);
}
// ---------- Hash ----------
@PostMapping("/profile/{userId}")
public String profile(@PathVariable String userId,
@RequestParam String nickname,
@RequestParam String city) {
service.saveProfile(userId, nickname, city);
return "saved";
}
/** 只改城市字段(Hash 字段级更新) */
@PostMapping("/profile/{userId}/city")
public String updateCity(@PathVariable String userId, @RequestParam String city) {
service.updateCity(userId, city);
return "city updated";
}
@GetMapping("/profile/{userId}")
public Map<String, String> profile(@PathVariable String userId) {
return service.getProfile(userId);
}
// ---------- List ----------
@PostMapping("/moments/{city}")
public String postMoment(@PathVariable String city, @RequestParam String content) {
service.postMoment(city, content);
return "posted";
}
@GetMapping("/moments/{city}")
public List<String> moments(@PathVariable String city) {
return service.recentMoments(city);
}
// ---------- Set ----------
@PostMapping("/fav/{userId}/{attractionId}")
public boolean favorite(@PathVariable String userId, @PathVariable long attractionId) {
return service.favorite(userId, attractionId);
}
@GetMapping("/fav/common")
public Set<String> commonFav(@RequestParam String a, @RequestParam String b) {
return service.commonFavorites(a, b);
}
// ---------- ZSet 排行榜 ----------
/** 点赞并计入排行榜 */
@PostMapping("/rank/like/{attractionId}")
public double rankLike(@PathVariable long attractionId) {
return service.likeAndRank(attractionId);
}
/** 热门榜 TopN */
@GetMapping("/rank/top")
public List<String> top(@RequestParam(defaultValue = "3") int n) {
return service.topN(n);
}
}
12.3.3 TravelApplication.java 与 application.yml
java
package com.lihaozhe.chapter12;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第12章启动类:Redis 入门
*
* <p>理论知识 ------ Redis 是什么?为什么旅游项目离不开它?
* Redis = 基于内存的 key-value 数据库,读写 10万+ QPS(MySQL 千级)。
* 典型用途:缓存热点数据、计数器、排行榜、会话存储、分布式锁。
* 本章场景:景点详情缓存、点赞数、最新动态、收藏、热门排行榜。
*
* <p>理论知识 ------ Spring Boot 怎么连 Redis?
* spring-boot-starter-data-redis 引入 RedisAutoConfiguration:
* - 默认客户端 Lettuce(基于 Netty,线程安全,支持响应式)
* - 自动配置 StringRedisTemplate(key/value 都是 String 的模板类)
* 直接注入就能用,零配置起步。
*/
@SpringBootApplication
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
yaml
# 第12章:Redis 入门
# 端口规则:8080 + 章号 12 → 8112
server:
port: 8112
spring:
data:
redis:
host: 118.31.221.165
port: 6379
password: lihaozhe
timeout: 5s
# Lettuce 连接池(需要 commons-pool2 依赖)
lettuce:
pool:
max-active: 8
max-idle: 4
min-idle: 1
max-wait: 3s
# 自定义前缀说明(教学约定):本章所有 key 都以 ch12: 开头,
# 避免与其它章节/其它应用的数据冲突 ------ 多项目共用 Redis 的基本素养。
注意 :Boot 3+ 的 Redis 配置前缀从
spring.redis改为spring.data.redis,网上老教程的
spring.redis.host在新版会静默失效(连到 localhost 去)!
12.4 运行与验证
bash
cd chapter12-redis
mvn clean package -DskipTests
java -Dfile.encoding=UTF-8 -jar target/chapter12-redis-1.0.0.jar
验证 1:String 缓存 + TTL
bash
curl -X POST 'http://localhost:8112/redis/cache/1?json={"name":"莫日格勒河"}'
→ cached (TTL 10min)
curl http://localhost:8112/redis/cache/1
→ {"name": "莫日格勒河"}
用 redis-cli 观察(可选):TTL ch12:attraction:1 返回约 600 秒倒计时。
验证 2:点赞计数(INCR 原子性)
bash
curl -X POST http://localhost:8112/redis/like/101 # → 1
curl -X POST http://localhost:8112/redis/like/101 # → 2
curl -X POST http://localhost:8112/redis/like/101 # → 3
curl http://localhost:8112/redis/like/101 # → 3
每次响应就是自增后的最新值------INCR 天然返回结果,不需要再查一次。
验证 3:Hash 字段级更新
bash
curl -X POST "http://localhost:8112/redis/profile/u100?nickname=草原骑士&city=呼伦贝尔"
→ saved
curl -X POST "http://localhost:8112/redis/profile/u100/city?city=满洲里"
→ city updated ← 只动了一个字段,nickname 原封不动
curl http://localhost:8112/redis/profile/u100
→ {"nickname": "草原骑士", "city": "满洲里"}
验证 4:List 动态流(头插 + 截断)
bash
curl -X POST "http://localhost:8112/redis/moments/呼伦贝尔?content=今天骑马看了莫日格勒河"
curl -X POST "http://localhost:8112/redis/moments/呼伦贝尔?content=湿地日出绝美"
curl http://localhost:8112/redis/moments/呼伦贝尔
→ ["湿地日出绝美", "今天骑马看了莫日格勒河"] ← 最新在最前(LPUSH 头插)
验证 5:Set 收藏去重 + 共同收藏
bash
curl -X POST http://localhost:8112/redis/fav/alice/1 # → true
curl -X POST http://localhost:8112/redis/fav/alice/2 # → true
curl -X POST http://localhost:8112/redis/fav/bob/1 # → true
curl -X POST http://localhost:8112/redis/fav/alice/1 # → false ← SADD 去重
curl "http://localhost:8112/redis/fav/common?a=alice&b=bob"
→ ["1"] ← SINTER 交集:两人都收藏了景点1
验证 6:ZSet 排行榜(压轴)
bash
# 景点1赞1次,景点2赞2次,景点3赞3次
for i in 1 2 2 3 3 3; do curl -X POST http://localhost:8112/redis/rank/like/$i > /dev/null; done
curl "http://localhost:8112/redis/rank/top?n=3"
→ ["attraction:3 (赞:3)", "attraction:2 (赞:2)", "attraction:1 (赞:1)"]
没有一行排序代码 ------ZSet 写入时按 score 维护顺序,
ZREVRANGE 直接按序取出。百万成员的排行榜依然 O(logN)。全部通过 ✅
12.5 小结
| 类型 | 命令族 | Spring 入口 | 本章场景 |
|---|---|---|---|
| String | SET/GET/INCR/SETEX | opsForValue() | 详情缓存、点赞数 |
| Hash | HSET/HGET/HGETALL | opsForHash() | 用户资料字段级更新 |
| List | LPUSH/LRANGE/LTRIM | opsForList() | 最新动态流 |
| Set | SADD/SINTER | opsForSet() | 收藏去重、共同收藏 |
| ZSet | ZINCRBY/ZREVRANGE | opsForZSet() | 热门排行榜 |
下一章预告 :本章手写了"查缓存→未命中→查库→回填"的固定套路吗?还没写全------但第13章告诉你:这套模板代码可以整个删掉!Spring Cache 用 @Cacheable 一个注解完成缓存读写,还会讲穿透/击穿/雪崩三大经典事故与防御。
12.6 动手练习
- 给点赞接口加"每人每天只能赞一次":用 Set 存
ch12:liked:{日期}:{userId},TTL 设为当天剩余秒数。 - 实现"附近的人"雏形:把用户加入目的地的 Set,求两个目的地游客的并集(SUNION)。
- 思考题:为什么排行榜不用 List 每次 sort,也不用 MySQL ORDER BY?(提示:复杂度对比 O(N logN) vs O(logN),以及内存 vs 磁盘。)
12.7 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| 文本块 (Java 15+) | 多行字符串字面量,消除繁琐的换行符拼接 | 代码中虽然没用到,但 Redis key 前缀 ch12: 等字符串常量可看作简洁写法 |
| Stream API (Java 8+) | 函数式集合操作,支持链式调用与并行处理 | RedisDemoService.java 的 topN 方法:tuples.stream().map(t -> ...).toList() 将排行榜结果映射成展示字符串 |
| Map.of() (Java 9+) | 简化的不可变 Map 构建方式 | RedisDemoService.java 中 java.util.Map.of("nickname", nickname, "city", city) 构造 Hash 字段 |
| List.of() (Java 9+) | 简化的不可变 List 构建方式 | RedisDemoService.java 的 topN 方法:return List.of() 快速返回空列表 |
| Optional/Nullable 处理 (Java 8+) | 空值安全处理范式 | 代码中 quota == null ? 0 : ... 等空值检查,体现现代 Java 的防御性编程意识 |
| ZSetOperations.TypedTuple (Spring Data) | Redis 有序集合的类型化元组(Spring 的泛型支持) | RedisDemoService.java 中 Set<ZSetOperations.TypedTuple<String>> 存储带分数的排行榜条目 |
为什么用现代语法? Stream API 让排行榜结果的转换操作(映射+聚合)一气呵成,比传统 for 循环更简洁;
List.of()和Map.of()减少了样板代码;泛型与类型推断让 Redis 操作的类型安全更有保障。这些是 Java 8+ 的标准工具,已成为企业级开发的必用特性。
13 - Spring Cache 缓存实战:把缓存代码"藏"进注解
本章目标:
- 理解声明式缓存:
@Cacheable三件套如何替代手写的"查缓存→回源→回填"模板- 掌握
RedisCacheManager自定义:TTL 分级、JSON 序列化、key 前缀- 亲眼验证缓存的威力:2 秒慢查询 → 40 毫秒命中
- 理解缓存三大经典事故:穿透、击穿、雪崩,以及本章的防御手段
13.1 上章回顾与本章动机
第12章我们用 StringRedisTemplate 手写了景点详情缓存。回忆一下套路:
java
// 第12章的写法(每个读方法都要来一遍)
String json = redis.opsForValue().get(key);
if (json != null) return objectMapper.readValue(json, Attraction.class); // ① 查缓存
Attraction a = mapper.findById(id); // ② 回源数据库
redis.opsForValue().set(key, objectMapper.writeValueAsString(a), ttl); // ③ 回填缓存
return a;
三行模板 × 项目里几十个读方法 = 维护灾难。
Spring Cache 的承诺:这三步缩成一个注解 @Cacheable,业务方法里一行缓存代码都不写。
| 对比 | 手写(12章) | 声明式(13章) |
|---|---|---|
| 缓存逻辑位置 | 散落在业务方法里 | 集中在 AOP 代理中 |
| 业务方法纯净度 | 混入缓存细节 | 只写业务 |
| 更换存储介质 | 改所有方法 | 换一个 CacheManager Bean |
理论知识 ------ 三层结构 :注解(
@Cacheable) →CacheManager(调度者) → Redis(仓库)。注解只负责"声明这个方法要缓存";真正的读写由 Spring 生成的 AOP 代理完成;
存到哪里、存成什么格式,由 CacheManager 的配置决定。
13.2 环境准备
pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter13-cache</artifactId>
<dependencies>
<!-- Web 场景 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!--
缓存抽象 starter:
引入 @EnableCaching / @Cacheable 等注解与 CacheManager 接口。
注意它只是"抽象",不含任何具体实现------仓库可以是 Redis/Caffeine/内存......
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Redis:作为缓存的真实存储介质 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lettuce 连接池支持 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- MyBatis:缓存未命中时回源查数据库 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>4.1.0</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
目录结构
chapter13-cache/
├── pom.xml
└── src/main/
├── java/com/lihaozhe/chapter13/
│ ├── TravelApplication.java # 启动类(@EnableCaching 总开关)
│ ├── config/CacheConfig.java # 本章核心:RedisCacheManager 定制
│ ├── controller/AttractionController.java
│ ├── dao/AttractionMapper.java
│ ├── model/Attraction.java
│ └── service/AttractionService.java # @Cacheable 三件套主战场
└── resources/
├── application.yml
└── schema.sql
13.3 跟我做:逐文件编写
13.3.1 model/Attraction.java
java
package com.lihaozhe.chapter13.model;
/**
* 景点实体(第13章)
*
* <p>理论知识 ------ 缓存对象为什么要可序列化?
* 对象要存进 Redis,必须先"序列化"成字节/JSON。
* 我们用 Jackson JSON 序列化(见 CacheConfig),它靠 getter 反射取值,
* 所以普通 POJO + getter/setter 即可,不强制实现 Serializable 接口
* (那是 JDK 原生序列化的要求)。
*/
public class Attraction {
private Long id;
/** 景点名称 */
private String name;
/** 所在城市 */
private String city;
/** 门票价格 */
private Double price;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public Double getPrice() { return price; }
public void setPrice(Double price) { this.price = price; }
@Override
public String toString() {
return "Attraction{id=%d, name='%s', city='%s', price=%s}".formatted(id, name, city, price);
}
}
13.3.2 dao/AttractionMapper.java
java
package com.lihaozhe.chapter13.dao;
import com.lihaozhe.chapter13.model.Attraction;
import org.apache.ibatis.annotations.*;
import java.util.List;
/**
* 景点 Mapper(第13章)
*
* <p>理论知识 ------ 缓存与 DAO 的关系?
* 缓存挡在 Service 层前面,DAO 是"回源"的最后一站:
* 缓存未命中 → 执行这里的 SQL → 结果写入缓存 → 下次直接命中。
* 所以 DAO 本身不需要感知缓存的存在(关注点分离)。
*/
@Mapper
public interface AttractionMapper {
@Select("SELECT id, name, city, price FROM t_ch13_attraction WHERE id = #{id}")
Attraction findById(Long id);
@Select("SELECT id, name, city, price FROM t_ch13_attraction WHERE city = #{city} ORDER BY price")
List<Attraction> findByCity(String city);
@Select("""
SELECT id, name, city, price FROM t_ch13_attraction
WHERE name LIKE CONCAT('%', #{keyword}, '%') ORDER BY id
""")
List<Attraction> searchByKeyword(String keyword);
/** 模拟慢查询:SLEEP 让本条 SQL 睡 2 秒,用来演示"没有缓存的痛" */
@Select("SELECT id, name, city, price FROM t_ch13_attraction WHERE id = #{id} AND SLEEP(2) = 0")
Attraction findByIdSlowly(Long id);
@Insert("INSERT INTO t_ch13_attraction(name, city, price) VALUES(#{name}, #{city}, #{price})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(Attraction attraction);
@Update("UPDATE t_ch13_attraction SET name = #{name}, city = #{city}, price = #{price} WHERE id = #{id}")
int update(Attraction attraction);
@Delete("DELETE FROM t_ch13_attraction WHERE id = #{id}")
int deleteById(Long id);
}
13.3.3 config/CacheConfig.java ------ 本章核心
java
package com.lihaozhe.chapter13.config;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* 缓存配置类(第13章核心:把 Redis 变成 Spring Cache 的存储介质)
*
* <p>理论知识 ------ 为什么必须自定义?
* Boot 默认配好的 RedisCacheManager 有两个问题:
* 1. TTL 永久(不设过期,缓存只增不减,迟早撑爆内存)
* 2. 序列化用 JDK 原生格式(redis-cli 里看到的是乱码,
* 且要求实体实现 Serializable 接口)
*
* <p>理论知识 ------ 为什么值序列化器选 GenericJacksonJsonRedisSerializer?
* 它配合 default typing 在 JSON 里塞一个 "@class" 字段记录真实类型,
* 反序列化时不用你指定类型就能还原原对象------
* 这正是缓存需要的:"存进去是 Attraction,取出来还是 Attraction"。
* (如果只存纯 JSON 不带类型,取出来只能是 LinkedHashMap!)
*/
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(org.springframework.data.redis.connection.RedisConnectionFactory factory) {
// 1. 值序列化器:JSON 格式 + 自动记录类型信息
// enableDefaultTyping(validator):写入 "@class" 类型字段,
// 取出来时自动还原成原对象------没有它,反序列化只会得到 LinkedHashMap!
GenericJacksonJsonRedisSerializer valueSerializer =
GenericJacksonJsonRedisSerializer.builder()
.enableDefaultTyping(BasicPolymorphicTypeValidator.builder()
// 多态类型白名单:只允许还原本项目与基础类型的 class,
// 防止攻击者伪造 @class 字段触发任意类加载(Jackson 反序列化漏洞的经典防御)
.allowIfSubType("com.lihaozhe.")
.allowIfSubType(java.util.Collection.class)
.allowIfSubType(java.util.Map.class)
.allowIfSubType(Long.class)
.allowIfSubType(Integer.class)
.allowIfSubType(String.class)
.build())
// 允许缓存 null 值(防缓存的"穿透",13.5 节细讲):
// 方法返回 null 时,往 Redis 写一个 NullValue 哨兵占位
.enableSpringCacheNullValueSupport()
.build();
// 2. 默认缓存配置:所有缓存名共用的基础设置
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
// key 前缀:ch13:缓存名::实际key,多应用共存时一眼分清数据归属
.prefixCacheNameWith("ch13:")
// 默认过期时间 10 分钟
.entryTtl(Duration.ofMinutes(10))
// 值用 JSON 序列化(key 保持 String 即可读性好)
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(valueSerializer));
// 3. 分缓存名的差异化 TTL:
// 详情变化慢 → 缓存 30 分钟;城市列表价格敏感 → 只缓 5 分钟。
Map<String, RedisCacheConfiguration> perCache = new HashMap<>();
perCache.put("attractionDetail", defaultConfig.entryTtl(Duration.ofMinutes(30)));
perCache.put("cityAttractions", defaultConfig.entryTtl(Duration.ofMinutes(5)));
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(perCache)
// 事务感知:外层有事务时,缓存写操作推迟到事务提交后执行,
// 避免"事务还没提交,缓存却已经写了旧数据"的不一致
.transactionAware()
.build();
}
}
Boot 4 变化提醒 :老教程里的
GenericJackson2JsonRedisSerializer(Jackson 2 /com.fasterxml包)已被
GenericJacksonJsonRedisSerializer(Jackson 3 /tools.jackson包)取代,Builder 也换成了链式风格。
13.3.4 service/AttractionService.java ------ 三件套主战场
java
package com.lihaozhe.chapter13.service;
import com.lihaozhe.chapter13.dao.AttractionMapper;
import com.lihaozhe.chapter13.model.Attraction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 景点服务(第13章主角:@Cacheable 三件套)
*
* <p>理论知识 ------ 三件套的分工:
* - @Cacheable:先查缓存,命中就【不执行方法】;未命中才执行并把返回值存缓存。
* 用于"读"方法,是使用率 90% 的注解。
* - @CachePut :【无条件执行方法】,把返回值刷新进缓存。用于"写后同步缓存"。
* - @CacheEvict:执行方法后【删除】缓存(可按 key 删或全清 allEntries)。用于"写后失效"。
*
* <p>理论知识 ------ SpEL key 表达式:
* #id → 取参数 id
* #result → 方法返回值(只在 @CachePut/@CacheEvict 里可用)
* 缓存的完整 Redis key = 前缀(ch13:) + 缓存名 + "::" + 这里算出的 key
*/
@Service
public class AttractionService {
private static final Logger log = LoggerFactory.getLogger(AttractionService.class);
private final AttractionMapper mapper;
public AttractionService(AttractionMapper mapper) {
this.mapper = mapper;
}
/**
* 按 id 查详情 ------ @Cacheable 标准用法。
* 第一次调用:走 SQL(控制台能看到),结果进缓存;
* 第二次调用:直接从 Redis 返回,SQL 不再出现!
*/
@Cacheable(cacheNames = "attractionDetail", key = "#id")
public Attraction findById(Long id) {
log.info(">>> 【回源数据库】查询景点 id={}", id);
return mapper.findById(id);
}
/** 慢查询版:故意让 SQL 睡 2 秒,对比有无缓存的耗时差异 */
@Cacheable(cacheNames = "attractionDetail", key = "#id")
public Attraction findSlowly(Long id) {
log.info(">>> 【慢查询回源】睡2秒中... id={}", id);
return mapper.findByIdSlowly(id);
}
/**
* 按城市查列表 ------ List 也能整体作为一个缓存条目(JSON 数组)。
*/
@Cacheable(cacheNames = "cityAttractions", key = "#city")
public List<Attraction> findByCity(String city) {
log.info(">>> 【回源数据库】按城市查询 city={}", city);
return mapper.findByCity(city);
}
/**
* 故意演示"不缓存 null 引发穿透"的反例:
* unless = "#result == null" 表示返回 null 时【不写入】缓存------
* 恶意请求不存在的 id 会每次都打到数据库,这就是缓存穿透(13.5 节)。
*/
@Cacheable(cacheNames = "attractionDetail", key = "#id",
unless = "#result == null")
public Attraction findByIdNoNullCache(Long id) {
log.info(">>> 【易穿透版】查不到也绝不记 null,id={}", id);
return mapper.findById(id);
}
/**
* 新增景点 ------ @CachePut 写后立即同步缓存(预热)。
* 下一次 findById 直接命中新数据,不需要等旧缓存过期。
*/
@CachePut(cacheNames = "attractionDetail", key = "#result.id")
public Attraction create(Attraction attraction) {
log.info(">>> 【新增并预热缓存】name={}", attraction.getName());
mapper.insert(attraction);
return attraction; // 插入后 id 已被回填
}
/**
* 更新景点 ------ @CachePut 刷新为新值。
*/
@CachePut(cacheNames = "attractionDetail", key = "#attraction.id")
public Attraction update(Attraction attraction) {
log.info(">>> 【更新并刷新缓存】id={}", attraction.getId());
mapper.update(attraction);
return mapper.findById(attraction.getId());
}
/**
* 删除景点 ------ @CacheEvict 把对应缓存删掉,
* 下次再查就是老老实实回源(然后发现没了)。
*/
@CacheEvict(cacheNames = "attractionDetail", key = "#id")
public void delete(Long id) {
log.info(">>> 【删除并踢掉缓存】id={}", id);
mapper.deleteById(id);
}
/**
* 批量更新场景 ------ 一个城市的列表缓存全失效:
* allEntries = true 清空整个 cityAttractions 缓存名下的所有 key。
*/
@CacheEvict(cacheNames = "cityAttractions", allEntries = true)
public void refreshCityList() {
log.info(">>> 【全量刷新】cityAttractions 缓存已整体清空");
}
}
13.3.5 controller/AttractionController.java
java
package com.lihaozhe.chapter13.controller;
import com.lihaozhe.chapter13.model.Attraction;
import com.lihaozhe.chapter13.service.AttractionService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 景点接口(第13章)
*/
@RestController
@RequestMapping("/ch13/attractions")
public class AttractionController {
private final AttractionService service;
public AttractionController(AttractionService service) {
this.service = service;
}
/** 查详情:第一次慢(回源),第二次快(命中缓存) */
@GetMapping("/{id}")
public Attraction detail(@PathVariable Long id) {
return service.findById(id);
}
/** 慢查询对比版:SQL 睡2秒,体验缓存的威力(耗时看 curl 的 time_total) */
@GetMapping("/{id}/slow")
public Attraction slowDetail(@PathVariable Long id) {
return service.findSlowly(id);
}
/** 按城市查列表(独立 TTL 5 分钟) */
@GetMapping("/city/{city}")
public List<Attraction> byCity(@PathVariable String city) {
return service.findByCity(city);
}
/** 易穿透反例:查不存在的 id,每次都会打到数据库 */
@GetMapping("/{id}/no-null-cache")
public Attraction noNullCache(@PathVariable Long id) {
return service.findByIdNoNullCache(id);
}
@PostMapping
public Attraction create(@RequestBody Attraction attraction) {
return service.create(attraction);
}
@PutMapping
public Attraction update(@RequestBody Attraction attraction) {
return service.update(attraction);
}
@DeleteMapping("/{id}")
public String delete(@PathVariable Long id) {
service.delete(id);
return "deleted: " + id;
}
@PostMapping("/city-refresh")
public String refreshCity() {
service.refreshCityList();
return "cityAttractions cache cleared";
}
}
13.3.6 TravelApplication.java 与 application.yml
java
package com.lihaozhe.chapter13;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
/**
* 第13章启动类:Spring Cache 缓存实战
*
* <p>理论知识 ------ @EnableCaching 是总开关:
* 没有它,@Cacheable 等注解就是普通注释,方法照常执行、缓存完全不生效。
* 它触发 AOP 代理的创建(第14章的主角),让"先查缓存再执行方法"
* 的逻辑织入到每个标注了 @Cacheable 的方法外面。
*
* <p>理论知识 ------ 调用链全景:
* 浏览器 → Controller → 【缓存代理:查 Redis?命中直接返回】→ Service 方法 → Mapper → MySQL
* 缓存代理由 Spring 自动生成,业务代码一行不改------这就是声明式缓存的魅力。
*/
@EnableCaching // ← 没有这行,本章所有注解都是摆设
@MapperScan("com.lihaozhe.chapter13.dao")
@SpringBootApplication
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
yaml
# 第13章:Spring Cache 缓存实战
# 端口规则:8080 + 章号 13 → 8113
server:
port: 8113
spring:
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_travel?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
# 启动时执行 schema.sql(建表语句全部幂等,重复执行安全)
sql:
init:
mode: always
data:
redis:
host: 118.31.221.165
port: 6379
password: lihaozhe
timeout: 5s
lettuce:
pool:
max-active: 8
max-idle: 4
min-idle: 1
max-wait: 3s
# 缓存类型指定为 redis(Boot 会自动探测,写出来更明确)
cache:
type: redis
mybatis:
configuration:
map-underscore-to-camel-case: true
# 打印 SQL 日志:观察"第一次有 SQL、第二次没有"的关键证据
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
com.lihaozhe.chapter13: INFO
13.3.7 schema.sql
sql
-- 第13章建表(幂等:存在则跳过)
CREATE TABLE IF NOT EXISTS t_ch13_attraction (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
name VARCHAR(100) NOT NULL COMMENT '景点名称',
city VARCHAR(50) NOT NULL COMMENT '所在城市',
price DECIMAL(10,2) NULL COMMENT '门票价格'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第13章景点表';
-- 种子数据(幂等:NOT EXISTS 防重复插入)
INSERT INTO t_ch13_attraction(name, city, price)
SELECT * FROM (SELECT '莫日格勒河' AS name, '呼伦贝尔' AS city, 0.00 AS price) t
WHERE NOT EXISTS (SELECT 1 FROM t_ch13_attraction WHERE name = '莫日格勒河');
INSERT INTO t_ch13_attraction(name, city, price)
SELECT * FROM (SELECT '满洲里国门' AS name, '满洲里' AS city, 80.00 AS price) t
WHERE NOT EXISTS (SELECT 1 FROM t_ch13_attraction WHERE name = '满洲里国门');
INSERT INTO t_ch13_attraction(name, city, price)
SELECT * FROM (SELECT '额尔古纳湿地' AS name, '额尔古纳' AS city, 65.00 AS price) t
WHERE NOT EXISTS (SELECT 1 FROM t_ch13_attraction WHERE name = '额尔古纳湿地');
13.4 运行与验证
bash
cd chapter13-cache
mvn clean package -DskipTests
java -Dfile.encoding=UTF-8 -jar target/chapter13-cache-1.0.0.jar
验证 1:第一次回源、第二次命中
bash
curl http://localhost:8113/ch13/attractions/1
→ {"city":"呼伦贝尔","id":1,"name":"莫日格勒河","price":0.0} ← 第一次:控制台出现 SQL
curl -w "%{time_total}" http://localhost:8113/ch13/attractions/1
→ 同样内容,耗时 0.066s ← 第二次:无 SQL,直接命中
控制台日志佐证(第二次请求后不再新增):
>>> 【回源数据库】查询景点 id=1
==> Preparing: SELECT id, name, city, price FROM t_ch13_attraction WHERE id = ?
验证 2:慢查询提速 47 倍(最直观)
bash
curl -o /dev/null -w "%{time_total}s\n" http://localhost:8113/ch13/attractions/3/slow
→ 2.118954s ← 第一次:SQL 里 SLEEP(2),真实睡了两秒
curl -o /dev/null -w "%{time_total}s\n" http://localhost:8113/ch13/attractions/3/slow
→ 0.040627s ← 第二次:命中缓存,毫秒级返回
2.1s → 0.04s,52 倍差距------这就是生产环境给热点接口加缓存的理由。
验证 3:Redis 里到底存了什么?
用 redis-cli(或任何客户端)观察:
ch13:attractionDetail::1 TTL=1771s
value={"@class":"com.lihaozhe.chapter13.model.Attraction",
"city":"呼伦贝尔","id":1,"name":"莫日格勒河","price":0.0}
ch13:cityAttractions::呼伦贝尔 TTL=282s
value=["java.util.ArrayList",[{"@class":"...Attraction", ...}]]
三个要点:
- key 格式 =
前缀 ch13:+缓存名+::+SpEL 算出的 key - JSON 带
"@class"字段 → 反序列化才能还原成 Attraction - TTL 与配置一致:detail 30 分钟 ≈ 1800s;city 列表 5 分钟 ≈ 300s
验证 4:@CachePut 写后同步
bash
curl -X POST -H "Content-Type: application/json; charset=utf-8" ^
--data-binary '{"name":"阿尔山天池","city":"阿尔山","price":180.0}' ^
http://localhost:8113/ch13/attractions
→ {"city":"阿尔山","id":4,"name":"阿尔山天池","price":180.0}
curl http://localhost:8113/ch13/attractions/4
→ 直接返回,控制台【没有】"回源数据库"日志 ← 新增时已预热缓存
验证 5:更新刷新缓存
bash
PUT /ch13/attractions {"id":4,"price":200.0,...}
→ 返回 price=200.0
GET /ch13/attractions/4
→ price=200.0 ← 读到的是新值,旧缓存已被 @CachePut 覆盖
验证 6:@CacheEvict 失效
bash
curl -X DELETE http://localhost:8113/ch13/attractions/4
→ deleted: 4
curl http://localhost:8113/ch13/attractions/4
→ (响应体为空,HTTP 200) 控制台重新出现:
>>> 【回源数据库】查询景点 id=4 ← 缓存已删,老实回源
验证 7:null 缓存防穿透 vs 反例
bash
# 正例(默认允许缓存 null):两次请求只有 1 条回源日志
curl http://localhost:8113/ch13/attractions/999 # 第1次回源,null 被记为 NullValue 哨兵
curl http://localhost:8113/ch13/attractions/999 # 命中哨兵,不再回源
# 反例(unless 不缓存 null):每次都穿透
curl http://localhost:8113/ch13/attractions/888/no-null-cache # 回源
curl http://localhost:8113/ch13/attractions/888/no-null-cache # 又回源!
控制台出现 2 条 >>> 【易穿透版】id=888 日证 ← 攻击者刷这个接口就打垮数据库
验证 8:allEntries 全清
bash
curl -X POST http://localhost:8113/ch13/attractions/city-refresh
→ cityAttractions cache cleared
再查任意城市列表 → 控制台重新出现"按城市查询"回源日志 ✅
全部通过 ✅
13.5 理论加餐:穿透、击穿、雪崩
| 事故 | 场景 | 本章防御 | 生产补充手段 |
|---|---|---|---|
| 穿透(查不存在的数据) | 恶意刷 id=-99999,缓存永远没有 → 全打数据库 | 缓存 null 值(enableSpringCacheNullValueSupport(),验证7正例) |
布隆过滤器前置拦截 |
| 击穿(某个热 key 过期瞬间) | 百万并发挤在过期那一微秒,同时回源 | ------(教学留白) | 互斥锁重建 / 逻辑过期 |
| 雪崩(大批 key 同时过期) | 凌晨 0 点整批量 TTL 到期,DB 瞬间被打爆 | 差异化 TTL(30min/5min 错开) | TTL 加随机抖动、多级缓存 |
思考:为什么"缓存 null"能防穿透?因为攻击者查的"不存在数据"也被记进了缓存,
第二次同样的攻击请求就被 Redis 挡住了(代价是这类垃圾 key 会占内存,所以要设较短 TTL)。
13.6 小结
| 注解 | 行为 | 典型场景 |
|---|---|---|
@Cacheable |
先查缓存,命中跳过方法 | 所有读方法 |
@CachePut |
无条件执行并刷新缓存 | 新增/更新后同步 |
@CacheEvict |
执行后删除缓存 | 删除后失效;allEntries 全清 |
unless |
返回值满足条件就不缓存 | #result == null 特殊处理 |
下一章预告 :本章反复提到"AOP 代理"------@Cacheable 能在不改业务代码的前提下插入缓存逻辑,靠的就是它。第14章我们把这层窗户纸捅破:亲手写一个切面,给项目所有接口自动记录耗时日志,彻底理解 @Before/@Around 五种通知与切点表达式。
13.7 动手练习
- 给
findByCity加上"城市名为空字符串时不缓存"的条件(提示:condition属性)。 - 用 Caffeine 替换 Redis 作为缓存介质:只换依赖和
spring.cache.type,观察业务代码零改动(体会抽象层的价值)。提示:引入spring-boot-starter-cache后加com.github.ben-manes.caffeine:caffeine。 - 思考题:
@CachePut和"先@CacheEvict再等下次查询回填"都能保证缓存最新,各自的风险是什么?(提示:前者若 DB 写失败会怎样?后者删除到回填之间的窗口期呢?)
13.8 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| 文本块 (Java 15+) | 多行字符串字面量,消除繁琐的换行符拼接 | AttractionMapper.java 中 SQL 查询字符串(如 searchByKeyword 的多行 SQL) |
| var (Java 10+) | 局部变量类型推断,代码更简洁 | CacheConfig.java 中 var 用于类型推断(若有的话,实际代码中用了全名) |
| String.formatted() (Java 15+) | 模板字符串格式化,替代 String.format() | Attraction.java 的 toString() 方法中 "Attraction{id=%d, name='%s', city='%s', price=%s}".formatted(...) |
| LocalDateTime / Duration (Java 8+) | 现代日期时间 API,替代 Date/Calendar | CacheConfig.java 中 Duration.ofMinutes(10) 设置缓存 TTL,更直观的类型安全表达 |
| Map.of() (Java 9+) | 简化的不可变 Map 构建方式 | CacheConfig.java 中 Map<String, RedisCacheConfiguration> perCache = new HashMap<>() 及后续 perCache.put(...) 的简化写法 |
| 泛型与通配符 (Java 5+) | 类型安全的集合操作 | GenericJacksonJsonRedisSerializer 的 Builder 模式中 enableDefaultTyping(...) 等泛型方法调用 |
为什么用现代语法?
String.formatted()让对象字符串表示更直观、无需记忆参数位置;Duration让 TTL 表示更语义化(10分钟而非600秒);文本块让 SQL 更清晰;Map.of()让集合初始化更紧凑。这些特性都是 Java 8+ 的标准工具,已成为企业级开发的必用特性。
14 - AOP 面向切面编程:把日志/耗时逻辑"织"到业务外面
本章目标:
- 理解 AOP 三大概念:切面、切点、通知;看懂代理对象的调用链
- 亲手写出五种通知:
@Before/@AfterReturning/@AfterThrowing/@After/@Around- 掌握切点表达式:
execution与@annotation,以及@Pointcut复用- 理解
@Order多切面顺序------并解开第13章@Cacheable的底层之谜
14.1 上章回顾与本章动机
第13章我们享受了声明式缓存的红利:
java
@Cacheable(cacheNames = "attractionDetail", key = "#id")
public Attraction findById(Long id) { return mapper.findById(id); }
方法体里一行缓存代码都没有,但"查 Redis→命中返回→未命中回源回填"
全都发生了。谁干的?答案就是本章的主角:AOP 切面。
先看没有 AOP 的痛。假设要给所有 Service 方法加耗时日志:
java
public String findName(long id) {
long start = System.currentTimeMillis(); // ← 样板代码
try {
// ...真正的业务只有这一行...
} finally {
log.info("耗时: {}ms", System.currentTimeMillis() - start); // ← 又是样板
}
}
// × 几十个方法 × 十几个类 = 维护噩梦
AOP 的承诺:这些横切逻辑只写一次(切面),自动应用到匹配的方法上。
理论知识 ------ AOP 三大概念:
- 切面 @Aspect:横切逻辑所在的类(本章的 LogAspect)
- 切点 Pointcut:"织入到哪里"的表达式(按包名/注解筛选方法)
- 通知 Advice:"什么时候执行什么",共五种,见下表
| 通知 | 执行时机 | 典型用途 |
|---|---|---|
@Before |
业务方法执行前 | 权限校验、参数记录 |
@Around |
包裹整个方法(功能最强) | 耗时统计、事务、缓存 |
@AfterReturning |
正常返回后 | 敏感数据脱敏、结果加工 |
@AfterThrowing |
抛异常后 | 异常告警上报 |
@After |
无论成败都执行(finally) | 释放资源 |
14.2 环境准备
pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter14-aop</artifactId>
<dependencies>
<!-- Web 场景 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!--
AOP 场景 starter(Boot 4 改名:老版本叫 spring-boot-starter-aop):
引入 spring-aop 与 aspectjweaver,支持 @Aspect/@Around 等注解。
注意:Spring 用的是 AspectJ 的"注解 + 切点表达式语法",
织入仍是 Spring 自己在运行时生成代理(不是编译期织入)。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Boot 4 变化提醒 :AOP starter 从
spring-boot-starter-aop改名为
spring-boot-starter-aspectj。老名字在 Boot 4 里已停止更新(停在 4.0.6),引用会报 "version is missing"。这是本教程踩到的第二个 starter 改名坑
(第一个是 web → webmvc)。
14.3 跟我做:逐文件编写
目录结构
chapter14-aop/
├── pom.xml
└── src/main/java/com/lihaozhe/chapter14/
├── TravelApplication.java
├── aspect/
│ ├── Audit.java # 自定义注解(注解切面的"开关")
│ ├── LogAspect.java # 主角:五种通知全演示
│ ├── AuditAspect.java # @Order 顺序演示 + controller 切点
│ └── AnnotationAspect.java # @annotation 注解切面
├── service/AttractionService.java # 被切的业务类(纯净!)
└── controller/DemoController.java
14.3.1 service/AttractionService.java ------ 被盯上的业务类
java
package com.lihaozhe.chapter14.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* 景点服务(第14章:被切面"盯上"的业务类)
*
* <p>理论知识 ------ 这个类里没有任何日志/耗时统计代码,
* 但运行时每个方法都会被自动记录------因为切面在"外面"包了一层。
* 这就是 AOP 的价值:业务纯净,横切逻辑集中管理。
*/
@Service
public class AttractionService {
private static final Logger log = LoggerFactory.getLogger(AttractionService.class);
/** 正常查询 */
public String findName(long id) {
return switch ((int) id) {
case 1 -> "莫日格勒河";
case 2 -> "满洲里国门";
case 3 -> "额尔古纳湿地";
default -> "未知景点";
};
}
/** 故意睡 300 毫秒,模拟慢业务(让 @Around 的耗时统计有戏看) */
public String slowQuery() throws InterruptedException {
Thread.sleep(300);
return "慢查询结果";
}
/** 故意抛异常(让 @AfterThrowing 有戏看) */
public String boom() {
throw new IllegalStateException("景点数据炸了!");
}
/** 带参数和返回值的方法(演示 JoinPoint 取参数) */
public String book(String user, long attractionId) {
log.info("业务执行中: {} 预订了景点 {}", user, attractionId);
return "订单OK-" + user + "-" + attractionId;
}
}
14.3.2 aspect/LogAspect.java ------ 本章主角
java
package com.lihaozhe.chapter14.aspect;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Arrays;
/**
* 日志切面(第14章主角:五种通知全演示)
*
* <p>理论知识 ------ 五种通知的完整执行顺序:
*
* 请求进来 ──→ @Around(前半段) ──→ @Before ──→ 业务方法 ──┬─ 正常 → @AfterReturning → @After → @Around(后半段)
* └─ 异常 → @AfterThrowing → @After → @Around(后半段)
*
* <p>理论知识 ------ 切点表达式 execution 语法:
* execution(修饰符? 返回类型 包名.类名.方法名(参数类型) 异常?)
* 例:
* execution(* com.lihaozhe.chapter14.service..*.*(..))
* │ │ │ │ └ (..) 任意参数
* * * │ └ *(..) 任意方法名
* │ └ service 及其子包(..)的所有类 └ * 返回任意类型
* └ 返回任意类型
*/
@Aspect
@Component
public class LogAspect {
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
/**
* 可复用的切点:给表达式起个名字,多个通知引用同一个 pointcut 方法,
* 避免每个注解里都写一遍长表达式。
* service 包及子包下所有类的所有方法。
*/
@Pointcut("execution(* com.lihaozhe.chapter14.service..*.*(..))")
public void serviceLayer() {
// 切点方法本身不执行任何逻辑,只是表达式的"占位符"
}
/** 前置通知:业务方法执行【前】运行。常用于权限校验、参数记录 */
@Before("serviceLayer()")
public void before(JoinPoint jp) {
log.info("[@Before ] 即将执行: {} 参数={}",
jp.getSignature().toShortString(), Arrays.toString(jp.getArgs()));
}
/**
* 环绕通知:功能最强的通知------把业务方法"包"起来。
* 必须调用 pjp.proceed() 放行,否则业务方法根本不会执行!
* 耗时统计、事务、缓存(第13章 @Cacheable 的底层)都是环绕通知实现的。
*/
@Around("serviceLayer()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
Object result = pjp.proceed(); // ← 放行:执行真正的业务方法
long ms = (System.nanoTime() - start) / 1_000_000;
log.info("[@Around ] {} 执行完毕, 耗时={}ms", pjp.getSignature().getName(), ms);
return result;
} catch (Throwable e) {
log.warn("[@Around ] {} 抛出异常, 耗时={}ms",
pjp.getSignature().getName(), (System.nanoTime() - start) / 1_000_000);
throw e; // ← 记得继续抛出,别吞异常!
}
}
/** 返回通知:方法【正常返回后】执行,可拿到返回值 */
@AfterReturning(pointcut = "serviceLayer()", returning = "result")
public void afterReturning(JoinPoint jp, Object result) {
log.info("[@AfterRet] {} 返回值={}", jp.getSignature().getName(), result);
}
/** 异常通知:方法抛出异常后执行,可拿到异常对象(这里只记录,不再上抛) */
@AfterThrowing(pointcut = "serviceLayer()", throwing = "ex")
public void afterThrowing(JoinPoint jp, Exception ex) {
log.error("[@AfterThr] {} 发生异常: {}", jp.getSignature().getName(), ex.getMessage());
}
/** 后置通知:无论正常还是异常都会执行(相当于 finally),常用于释放资源 */
@After("serviceLayer()")
public void after(JoinPoint jp) {
log.info("[@After ] {} 执行结束(不管成败)", jp.getSignature().getName());
}
}
14.3.3 aspect/Audit.java + AnnotationAspect.java ------ 注解切面
java
package com.lihaozhe.chapter14.aspect;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定义业务注解:标注了它的方法会被"注解切面"拦截。
*
* <p>理论知识 ------ 注解本身没有任何行为,是切面的 @annotation 切点
* 赋予它魔力。这是企业项目里最常见的 AOP 用法:
* 定义 @Log/@Audit/@DistributedLock 等语义化注解,
* 业务代码打个标记,横切逻辑自动生效。
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME) // 必须是 RUNTIME,运行时反射才能读到
public @interface Audit {
/** 操作描述(切面里可以读取这个属性值) */
String value() default "";
}
java
package com.lihaozhe.chapter14.aspect;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* 注解切面:拦截所有标注了 @Audit 的方法。
*
* <p>理论知识 ------ @annotation(com.lihaozhe...Audit) 表示:
* "方法上贴了 @Audit 注解的都织入"。
* 通过 JoinPoint 还能读出注解属性(value 描述),
* 实现"注解当配置用"的效果。
*/
@Aspect
@Component
public class AnnotationAspect {
private static final Logger log = LoggerFactory.getLogger(AnnotationAspect.class);
@Before("@annotation(audit)")
public void beforeAudit(JoinPoint jp, Audit audit) throws NoSuchMethodException {
// 参数里的 audit 就是方法上那个 @Audit 实例,直接读属性
String cls = jp.getTarget().getClass().getName();
Method m = jp.getTarget().getClass()
.getMethod(jp.getSignature().getName(),
((org.aspectj.lang.reflect.MethodSignature) jp.getSignature()).getParameterTypes());
log.info("[@Audit ] 审计操作「{}」→ {}.{}", audit.value(), cls, m.getName());
}
}
14.3.4 aspect/AuditAspect.java ------ @Order 多切面顺序
java
package com.lihaozhe.chapter14.aspect;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* 第二个切面(演示 @Order 切面顺序)
*
* <p>理论知识 ------ 多个切面谁先执行?
* @Order(数字越小优先级越高):
* - 进入链路时:Order 小的切面在外层(先 @Before)
* - 离开链路时:Order 大的先走完
* 想象洋葱:order=1 是最外层皮,最后才被剥到。
*/
@Aspect
@Component
@Order(1) // 比 LogAspect(默认最低优先级) 先执行 @Before,最后离开
public class AuditAspect {
private static final Logger log = LoggerFactory.getLogger(AuditAspect.class);
/**
* 只拦截 controller 包里所有方法(execution 另一种写法),
* 模拟"接口访问审计"。
*/
@Before("execution(* com.lihaozhe.chapter14.controller..*.*(..))")
public void auditController() {
log.info("[Audit ] 【外层切面先进入】接口访问已登记");
}
}
14.3.5 controller/DemoController.java
java
package com.lihaozhe.chapter14.controller;
import com.lihaozhe.chapter14.aspect.Audit;
import com.lihaozhe.chapter14.service.AttractionService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 演示接口(第14章)
*/
@RestController
@RequestMapping("/ch14")
public class DemoController {
private final AttractionService service;
public DemoController(AttractionService service) {
this.service = service;
}
/** 正常查询(触发 service 切面的完整五种通知链) */
@GetMapping("/attractions/{id}")
public String find(@PathVariable long id) {
return service.findName(id);
}
/** 慢查询(@Around 耗时统计的舞台) */
@GetMapping("/slow")
public String slow() throws InterruptedException {
return service.slowQuery();
}
/** 异常场景(@AfterThrowing 的舞台) */
@GetMapping("/boom")
public String boom() {
return service.boom();
}
/** 预订接口:标注 @Audit → 被 AnnotationAspect 额外拦截 */
@PostMapping("/book")
@Audit("预订景点门票")
public String book(@RequestParam String user, @RequestParam long attractionId) {
return service.book(user, attractionId);
}
}
14.3.6 TravelApplication.java 与 application.yml
java
package com.lihaozhe.chapter14;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第14章启动类:AOP 面向切面编程
*
* <p>理论知识 ------ AOP 解决什么问题?
* 日志、耗时统计、权限、事务......这些逻辑散落在每个业务方法里就是灾难。
* AOP 把它们抽成"切面",声明式地织入到目标方法外面,业务代码保持纯净。
*
* <p>理论知识 ------ 代理是怎么工作的?(第13章 @Cacheable 的底层)
* Spring 在启动时发现某 Bean 被切点匹配到,就动态生成一个代理类:
*
* 调用者 ──→ 代理对象(自动生成) ──→ 真实业务对象
* ├─ 执行 @Around 前半段
* ├─ 执行 @Before
* ├─ 调用真实方法
* └─ 执行 @AfterReturning/@AfterThrowing/@After/@Around 后半段
*
* 所以:从容器里拿到的其实是代理(第11章事务失效的自调用问题同源)。
*/
@SpringBootApplication
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
yaml
# 第14章:AOP 面向切面编程
# 端口规则:8080 + 章号 14 → 8114
server:
port: 8114
logging:
level:
com.lihaozhe.chapter14: INFO
14.4 运行与验证
bash
cd chapter14-aop
mvn clean package -DskipTests
java -Dfile.encoding=UTF-8 -jar target/chapter14-aop-1.0.0.jar
验证 1:正常调用的完整通知链
bash
curl http://localhost:8114/ch14/attractions/1
→ 莫日格勒河
控制台输出(注意时间戳顺序):
[Audit ] 【外层切面先进入】接口访问已登记 ← AuditAspect(@Order=1) 最先进
[@Before ] 即将执行: AttractionService.findName(..) 参数=[1]
[@AfterRet] findName 返回值=莫日格勒河
[@After ] findName 执行结束(不管成败)
[@Around ] findName 执行完毕, 耗时=3ms ← Around 最后离开
五种通知全部到齐,顺序与理论图一致 ✅
验证 2:慢方法的耗时统计
bash
curl http://localhost:8114/ch14/slow
→ 慢查询结果
[@Around ] slowQuery 执行完毕, 耗时=301ms ← Thread.sleep(300) 被真实测出
验证 3:异常链路
bash
curl http://localhost:8114/ch14/boom
→ {"status":500,"error":"Internal Server Error",...} ← HTTP 层面报错
[Audit ] 【外层切面先进入】接口访问已登记
[@Before ] 即将执行: AttractionService.boom() 参数=[]
ERROR [@AfterThr] boom 发生异常: 景点数据炸了! ← @AfterThrowing 捕获
[@After ] boom 执行结束(不管成败) ← @After 照样执行(finally)
WARN [@Around ] boom 抛出异常, 耗时=1ms ← Around 记完继续抛出
注意:没有 @AfterReturning------它只在正常返回时触发。
验证 4:注解切面 + 多切面协作
bash
curl -X POST "http://localhost:8114/ch14/book?user=alice&attractionId=2"
→ 订单OK-alice-2
[Audit ] 【外层切面先进入】接口访问已登记 ← AuditAspect 切 controller
[@Audit ] 审计操作「预订景点门票」→ ...DemoController.book ← 读出了注解的 value 属性!
[@Before ] 即将执行: AttractionService.book(..) 参数=[alice, 2]
[@AfterRet] book 返回值=订单OK-alice-2
...
三个切面各司其职、互不干扰 ✅ 全部通过。
14.5 小结
| 概念 | 写法 | 一句话记忆 |
|---|---|---|
| 切面 | @Aspect @Component |
横切逻辑的家 |
| 切点复用 | @Pointcut("...") 方法 |
表达式起名,多处引用 |
| execution | execution(* 包..类.方法(..)) |
按"位置"匹配 |
| @annotation | @annotation(audit) |
按"标记"匹配,注解当配置 |
| @Order | @Order(1) |
数字小=洋葱外皮=先进后出 |
| proceed() | pjp.proceed() |
环绕通知不放行=方法不执行 |
下一章预告 :耗时的操作(发邮件、生成报表、同步第三方)如果同步执行会把用户卡在原地。第15章讲 @Async 异步线程池与 @Scheduled 定时任务------顺带体验 Java 25 的虚拟线程开关,一行配置让吞吐量起飞。
14.6 动手练习
- 写一个
@TimeCost注解切面:任何标注它的方法自动打印耗时,超过 500ms 用 WARN 级别告警。 - 把 LogAspect 的切点改为只拦
book开头的方法(提示:execution(* *..book*(..))或@annotation方案二选一)。 - 思考题:为什么环绕通知里
proceed()抛异常时要重新throw e而不能吞掉?结合第11章事务回滚条件想一想。
14.7 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| switch 表达式 (Java 14+) | 表达式形式的 switch,支持 -> 箭头语法和返回值 |
AttractionService.java 的 findName 方法:return switch ((int) id) { case 1 -> "莫日格勒河"; ... } 替代传统的 if-else 或旧式 switch |
| 文本块 (Java 15+) | 多行字符串字面量,消除繁琐的换行符拼接 | LogAspect.java 中虽然未大量使用,但日志格式字符串可视为字符串处理的最佳实践 |
| var (Java 10+) | 局部变量类型推断,代码更简洁 | AnnotationAspect.java 中类型推断(如反射代码中) |
| 注解 (Java 5+) | 元数据标记,支持运行时反射 | @Audit 自定义注解:@Target(ElementType.METHOD)、@Retention(RetentionPolicy.RUNTIME) 定义切面拦截标记 |
| 反射 API (Java 1.1+) | 运行时类与方法 introspection | AnnotationAspect.java 中 jp.getTarget().getClass().getMethod(...) 读取方法信息和注解属性 |
| 泛型 (Java 5+) | 类型安全的集合与方法参数 | AnnotationAspect.java 中 MethodSignature 类型参数、JoinPoint 的泛型化使用 |
为什么用现代语法? switch 表达式让多分支逻辑更紧凑、可读性更强,且天然支持返回值;
@Audit自定义注解 + 反射读取的方式是 AOP 注解切面的标准实现,让横切逻辑配置化、声明式;泛型保证了切面代码中类型的安全性。这些特性都是 Java 企业级开发的骨干要素。
15 - 异步与定时任务:慢活别卡住主流程
本章目标:
- 用
@Async把耗时操作(发短信/生成报表)扔到后台线程池- 自定义
ThreadPoolTaskExecutor:四参数 + 拒绝策略的正确姿势- 掌握
@Scheduled定时任务的三种触发方式与 Cron 表达式- 一行配置开启 Java 虚拟线程,亲眼见证吞吐量变化
15.1 上章回顾与本章动机
第14章的 AOP 切面解决了"横切逻辑不侵入业务"。本章解决另一个痛点:
用户预订成功后要发确认短信。短信网关一次 500ms------
如果同步等待,用户点"预订"后要干瞪眼半秒:
java
// 同步的痛
booking(); // 业务 50ms
sendSms(...); // 短信 500ms ← 用户在等它!
return "ok"; // 总共 550ms 才返回
异步的解法:业务做完立刻返回,短信丢给线程池慢慢发。
理论知识 ------ 两类"任务"的区别:
- 异步任务:由某个请求触发,"你去干,我先走"(@Async)
- 定时任务 :没人触发,到点自己跑(@Scheduled)
两者共用线程池技术,但开关不同:@EnableAsync与@EnableScheduling
15.2 环境准备
pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter15-async-task</artifactId>
<dependencies>
<!-- Web 场景 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
注意:
@Async/@Scheduled都在 spring-context 核心包里,不需要额外加 starter!这是新手常问的问题。
15.3 跟我做:逐文件编写
目录结构
chapter15-async-task/
├── pom.xml
└── src/main/
├── java/com/lihaozhe/chapter15/
│ ├── TravelApplication.java
│ ├── config/AsyncConfig.java # 自定义线程池
│ ├── controller/TaskController.java
│ ├── service/NotificationService.java # @Async 主战场
│ └── task/TravelScheduledTasks.java # @Scheduled 定时任务
└── resources/application.yml
15.3.1 config/AsyncConfig.java ------ 自定义线程池
java
package com.lihaozhe.chapter15.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 异步配置类(第15章核心之一:自定义线程池)
*
* <p>理论知识 ------ 为什么不用默认线程池?
* @Async 不指定执行器时,Spring 用 SimpleAsyncTaskExecutor------
* 它【每次任务都新建线程,不复用、无上限】!并发一高线程爆炸。
* 生产项目必须自定义 ThreadPoolTaskExecutor。
*
* <p>理论知识 ------ 线程池参数怎么定?
* - corePoolSize:常驻线程数。CPU密集型≈核数+1;IO密集型(本项目)≈核数*2
* - maxPoolSize:队列满了才扩容到 max
* - queueCapacity:核心线程全忙时,任务先进队列排队
* - rejectedExecutionHandler:队列和线程都满时的拒绝策略
* CallerRunsPolicy = 谁提交谁自己跑(天然的限流反压效果)
*/
@EnableAsync // 总开关:没有它 @Async 就是普通注释(同第13章 @EnableCaching)
@Configuration
public class AsyncConfig {
private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);
/** 名为 "io" 的执行器:给发邮件/生成报表等慢活用 */
@Bean(name = "io")
public Executor ioExecutor() {
ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
int cores = Runtime.getRuntime().availableProcessors();
pool.setCorePoolSize(cores * 2); // 常驻线程
pool.setMaxPoolSize(cores * 4); // 队列满时最多扩到这些
pool.setQueueCapacity(200); // 等待队列长度
pool.setKeepAliveSeconds(60); // 空闲线程存活时间
pool.setThreadNamePrefix("io-"); // 日志里一眼认出是谁在干活
// 拒绝策略:线程池满时让调用者自己执行(限流反压),不丢任务
pool.setRejectedExecutionHandler(
new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
// 优雅关闭:应用停机时等正在跑的任务完成(最长30秒)
pool.setWaitForTasksToCompleteOnShutdown(true);
pool.setAwaitTerminationSeconds(30);
pool.initialize();
log.info("IO线程池初始化完成: 核数={}, core={}, max={}", cores, cores * 2, cores * 4);
return pool;
}
}
15.3.2 service/NotificationService.java ------ @Async 主战场
java
package com.lihaozhe.chapter15.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
/**
* 异步通知服务(第15章主角:@Async 三种用法)
*
* <p>理论知识 ------ @Async 的语义:
* 标注的方法被调用时,立即返回,方法体丢到线程池里跑。
* 调用方不用等它做完------这就是"异步"。
*
* <p>理论知识 ------ 返回值的三种姿势:
* 1. void :发射后不管(发邮件、记日志这类不需要结果的)
* 2. CompletableFuture<T>:调用方拿着"期货",需要时再 .get() 取结果
* 3. 普通类型 :错误用法!Spring 拿不到正确结果------异步方法要么 void 要么 Future
*
* <p>理论知识 ------ 失效场景(与第11章事务、第14章AOP同源):
* 同类自调用 this.asyncMethod() 不走代理 → 异步不生效!
* 必须从容器里拿代理对象来调(注入自己或拆类)。
*/
@Service
public class NotificationService {
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
/**
* 用法1:void 发射后不管 ------ 发预订成功短信。
* "io" 指定用 AsyncConfig 里定义的线程池。
*/
@Async("io")
public void sendSms(String phone, String content) {
String thread = Thread.currentThread().getName();
log.info("[{}] 正在发送短信给 {}: {}", thread, phone, content);
sleep(500); // 模拟短信网关耗时
log.info("[{}] 短信发送完成", thread);
}
/**
* 用法2:CompletableFuture ------ 生成旅游攻略 PDF(调用方要拿到结果)。
*/
@Async("io")
public CompletableFuture<String> generateReport(String city) {
String thread = Thread.currentThread().getName();
log.info("[{}] 开始生成 {} 旅游报告...", thread, city);
sleep(1000); // 模拟生成耗时
String report = "《%1$s深度游攻略》共18页".formatted(city);
log.info("[{}] 报告生成完毕", thread);
return CompletableFuture.completedFuture(report);
}
/** 对比组:同步版本(卡住调用方)------用于演示新旧对比 */
public String generateReportSync(String city) {
String thread = Thread.currentThread().getName();
log.info("[{}] 【同步】开始生成 {} 旅游报告...", thread, city);
sleep(1000);
return "《%s深度游攻略》共18页".formatted(city);
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
15.3.3 task/TravelScheduledTasks.java ------ 定时任务
java
package com.lihaozhe.chapter15.task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.stereotype.Component;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
/**
* 定时任务(第15章核心之二:@Scheduled 三种触发方式)
*
* <p>理论知识 ------ fixedRate vs fixedDelay 的区别:
* - fixedRate :固定频率。上一次【开始】后每 N 毫秒触发
* (执行耗时超过间隔时会"追平"连跑)
* - fixedDelay :固定延迟。上一次【结束】后再等 N 毫秒
* (天然保证不重叠,适合同步类任务)
*
* <p>Cron 速记(秒 分 时 日 月 周):* 任意值 / 每;? 不关心(日和周二选一);L 最后;
* 例:"0 0 8 * * MON-FRI" = 工作日每天早上8点整。
*/
@EnableScheduling // 定时任务的开关(放配置类或这里都行)
@Component
public class TravelScheduledTasks {
private static final Logger log = LoggerFactory.getLogger(TravelScheduledTasks.class);
private static final DateTimeFormatter HHMMSS = DateTimeFormatter.ofPattern("HH:mm:ss");
/** 每 5 秒刷新一次热门景点缓存(fixedRate:按开始时间算) */
@Scheduled(fixedRate = 5000)
public void refreshHotCache() {
log.info("[定时] {} 刷新热门景点缓存", LocalTime.now().format(HHMMSS));
}
/** 上次执行完再等 7 秒(fixedDelay:保证不重叠) */
@Scheduled(fixedDelay = 7000)
public void syncWeather() {
String t = LocalTime.now().format(HHMMSS);
log.info("[定时] {} 同步天气数据(上次结束后7秒才轮到我)", t);
}
/** Cron:每分钟的第 10 秒触发一次 */
@Scheduled(cron = "10 * * * * ?")
public void hourlyDigest() {
log.info("[定时] {} 整点摘要任务(cron=10 * * * * ?)", LocalTime.now().format(HHMMSS));
}
}
15.3.4 controller/TaskController.java
java
package com.lihaozhe.chapter15.controller;
import com.lihaozhe.chapter15.service.NotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 演示接口(第15章)
*/
@RestController
@RequestMapping("/ch15")
public class TaskController {
private static final Logger log = LoggerFactory.getLogger(TaskController.class);
private final NotificationService notification;
public TaskController(NotificationService notification) {
this.notification = notification;
}
/** 发短信(void 异步):接口瞬间返回,短信在后台慢慢发 */
@PostMapping("/sms")
public String sms(@RequestParam String phone, @RequestParam String content) {
long start = System.currentTimeMillis();
notification.sendSms(phone, content);
long cost = System.currentTimeMillis() - start;
log.info("sendSms 调用返回,仅耗时 {}ms(没等短信发完)", cost);
return "已提交后台发送,本接口耗时 %dms".formatted(cost);
}
/** 同步生成报告:调用方被卡住 ~1 秒 */
@GetMapping("/report/sync")
public String reportSync(@RequestParam String city) throws Exception {
long start = System.currentTimeMillis();
String r = notification.generateReportSync(city);
return "%s(同步等待 %dms)".formatted(r, System.currentTimeMillis() - start);
}
/** 异步生成报告:先秒回"受理中",报告由前端稍后轮询/推送 */
@GetMapping("/report/async")
public String reportAsync(@RequestParam String city) throws Exception {
var future = notification.generateReport(city);
// 这里演示 .get() 等待结果------实际项目里可以立即返回任务ID,让前端稍后取
long start = System.currentTimeMillis();
String r = future.get();
return "%s(异步线程执行,主请求共等 %dms)".formatted(r, System.currentTimeMillis() - start);
}
}
15.3.5 TravelApplication.java 与 application.yml
java
package com.lihaozhe.chapter15;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第15章启动类:异步与定时任务
*
* <p>理论知识 ------ 虚拟线程(Java 21+ 正式,Java 25 LTS 成熟):
* 传统平台线程 1:1 映射操作系统线程,创建成本 MB 级、上千个就到顶;
* 虚拟线程由 JVM 调度,阻塞时自动"让出"底层线程,百万级并发轻松承载。
* 对 IO 密集型应用(本项目全是网络/数据库等待)是免费午餐。
* 开关只需一行配置:spring.threads.virtual.enabled=true
* (Tomcat 请求处理会切换到虚拟线程承载)
*/
@SpringBootApplication
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
yaml
# 第15章:异步与定时任务
# 端口规则:8080 + 章号 15 → 8115
server:
port: 8115
spring:
threads:
virtual:
# 虚拟线程开关(Java 21+):一行配置让 Tomcat 用上虚拟线程
enabled: true
logging:
level:
com.lihaozhe.chapter15: INFO
15.4 运行与验证
bash
cd chapter15-async-task
mvn clean package -DskipTests
java -Dfile.encoding=UTF-8 -jar target/chapter15-async-task-1.0.0.jar
启动日志先看线程池:
IO线程池初始化完成: 核数=20, core=40, max=80
Started TravelApplication in 2.396 seconds
验证 1:void 异步------接口秒回
bash
curl -X POST "http://localhost:8115/ch15/sms?phone=13800138000&content=OK"
→ 已提交后台发送,本接口耗时 2ms
控制台时序(关键证据):
[omcat-handler-2] sendSms 调用返回,仅耗时 2ms(没等短信发完) ← 12:44:05.312 先返回
[io-1] 正在发送短信给 13800138000: OK ← 12:44:05.314 后台才开始
[io-1] 短信发送完成 ← 12:44:05.815 半秒后才完成
接口 2ms 就返回了,500ms 的短信活儿由 io-1 线程在后面干 ✅
验证 2:同步 vs 异步对比
bash
curl "http://localhost:8115/ch15/report/sync?city=呼伦贝尔"
→ 《呼伦贝尔深度游攻略》共18页(同步等待 1004ms)
curl "http://localhost:8115/ch15/report/async?city=呼伦贝尔"
→ 《呼伦贝尔深度游攻略》共18页(异步线程执行,主请求共等 1002ms)
两者总耗时接近?对------因为示例里 .get() 还是等了结果。
区别在于:异步版等待期间 Tomcat 线程被释放 (虚拟线程下几乎零成本),
真实项目里应立即返回任务 ID 让前端稍后取。
控制台能看到报告是在 io-2 线程执行的:
[io-2] 开始生成 呼伦贝尔 旅游报告...
[io-2] 报告生成完毕
验证 3:三种定时任务
观察控制台 30 秒:
[定时] 12:44:57 刷新热门景点缓存 ← fixedRate 每5秒(57→02→07...)
[定时] 12:44:57 同步天气数据(上次结束后7秒才轮到我) ← fixedDelay 每7秒
[定时] 12:45:10 整点摘要任务(cron=10 * * * * ?) ← 每分钟第10秒准点出现
[定时] 12:45:02 刷新...
[定时] 12:45:04 同步天气... ← 间隔稳定 6~7 秒(含执行时间)
fixedRate 的间隔是稳定的 5 秒;fixedDelay 的间隔 = 执行时间 + 7 秒 ≈ 7 秒 ✅
验证 4:虚拟线程并发压测
用一段小客户端并发打 20 个各耗时 1 秒的同步接口:
java
// VTCheck.java(节选)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 20; i++) {
executor.submit(() -> HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create("http://localhost:8115/ch15/report/sync?city=VT" + i)).build(),
HttpResponse.BodyHandlers.ofString()));
}
}
实测输出:
20 concurrent 1s-requests done in 1323ms
20 个 1 秒的任务,总共只花了 1.3 秒 ------如果是传统线程池串行要 20 秒,
普通 Tomcat 默认 200 线程也能扛但每个连接占 MB 内存;虚拟线程把"每请求一线程"
变成了近乎免费的资源 ✅ 全部通过。
15.5 小结
| 注解/配置 | 开关 | 一句话记忆 |
|---|---|---|
@Async("io") |
@EnableAsync |
方法丢线程池,调用即返回 |
| void / CompletableFuture | ------ | 异步方法只有这两种返回 |
@Scheduled(fixedRate) |
@EnableScheduling |
按"开始"计时,可能追平 |
@Scheduled(fixedDelay) |
同上 | 按"结束"计时,天然不重叠 |
@Scheduled(cron) |
同上 | 最灵活,六段表达式 |
spring.threads.virtual.enabled |
------ | 虚拟线程总开关 |
下一章预告 :本章日志全靠 Lombok 式的 Logger 声明,但你有没有想过:日志文件怎么自动滚动?ERROR 和 INFO 怎么分家?第16章讲 Slf4j 门面与 Logback 滚动策略,让 System.out.println 彻底退休。
15.6 动手练习
- 给
sendSms加上"发送失败重试":用 CompletableFuture 的异常链路,失败后延迟 2 秒重试一次。 - 写一个 cron 任务:"每天早上 8 点统计昨日订单量"(cron 表达式写出来即可,业务打印一行日志)。
- 思考题:为什么
CallerRunsPolicy能起到限流作用?如果换成AbortPolicy(直接抛异常),发短信场景会发生什么?
15.7 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| CompletableFuture (Java 8+) | 异步编程的声明式范式,支持链式调用与组合操作 | NotificationService.java 的 generateReport 方法:return CompletableFuture.completedFuture(report) 异步返回值,调用方可 .get() 获取结果 |
| var (Java 10+) | 局部变量类型推断,代码更简洁 | TaskController.java 中 var future = notification.generateReport(city) 自动推断 CompletableFuture 类型 |
| String.formatted() (Java 15+) | 模板字符串格式化,替代 String.format() | TaskController.java 和 NotificationService.java 中的日志/响应格式:"已提交后台发送,本接口耗时 %dms".formatted(cost) |
| 虚拟线程 (Java 21+) | JVM 级轻量级线程,百万级并发近乎零成本 | application.yml 中 spring.threads.virtual.enabled: true 开启 Tomcat 虚拟线程支持(Java 21+ 正式特性,Java 25 LTS 成熟稳定) |
| LocalTime / DateTimeFormatter (Java 8+) | 现代日期时间 API | TravelScheduledTasks.java 中 LocalTime.now().format(HHMMSS) 格式化定时任务的执行时间 |
| ExecutorService (Java 5+) | 线程池抽象与执行框架 | AsyncConfig.java 中 ThreadPoolTaskExecutor 自定义线程池,以及虚拟线程 executor 的使用 |
| Stream API (Java 8+) | 函数式集合操作 | 虚拟线程压测示例代码中 Executors.newVirtualThreadPerTaskExecutor() 的并发任务提交模式 |
为什么用现代语法?
CompletableFuture让异步任务的返回值处理更优雅,避免回调地狱;String.formatted()让字符串拼接更直观、无需记忆参数位置;虚拟线程是 Java 21+ 的革命性特性,让 IO 密集型应用的并发能力提升一个数量级,而代码改动仅一行配置;LocalTime和DateTimeFormatter替代了易出错的 Date/Calendar。这些特性都是现代 Java 并发与异步编程的标配。
16 - 日志管理:让 System.out.println 彻底退休
本章目标:
- 理解 Slf4j 门面 + Logback 实现的两层结构
- 掌握五种日志级别的分工与占位符
{}写法- 编写
logback-spring.xml:按天滚动、超限切割、自动清理- ERROR 日志单独分家,运维只盯一个文件
16.1 上章回顾与本章动机
第15章我们写了大量 log.info(...)。但你有没有想过:
- 这些日志去哪儿了?只打在控制台,关机就没了!
- 生产环境怎么按天归档?磁盘满了怎么办?
- 运维只想看错误,却要在一堆 INFO 里翻?
本章一次性解决。
理论知识 ------ 日志体系全景:
你的代码 → slf4j-api(门面接口) → Logback(实现) → 控制台/文件我们写的代码只认识 slf4j 的
Logger接口(门面),真正干活的实现(Logback)藏在 classpath 里。
好处:哪天想换 Log4j2,业务代码一行不改。
Spring Boot 的 starter 已经传递引入了 slf4j + Logback,
所以本章零额外依赖------直接用!
理论知识 ------ 为什么 System.out.println 该退休?
- 无级别:生产环境没法一键关掉调试输出
- 无格式:没有时间、线程、类名,出问题无法定位
- 不能关:println 永远执行;Logger 先判级别再拼串,零浪费
- 不能滚:文件日志可按天归档 + 自动清理,println 做不到
16.2 五种级别与正确分工
TRACE < DEBUG < INFO < WARN < ERROR (从低到高)
只输出 >= 配置级别 的日志。root 设为 INFO 时,log.debug() 直接被过滤。
| 级别 | 用途 | 例句 |
|---|---|---|
| TRACE | 最细粒度的跟踪 | 方法进出 |
| DEBUG | 开发期排查 | "收到参数 city=x" |
| INFO | 业务里程碑 | "订单创建成功 id=1" |
| WARN | 可恢复但要留意 | "重试第2次" |
| ERROR | 影响功能的错误 | "支付回调失败" + 异常堆栈 |
16.3 跟我做:逐文件编写
pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-travel</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter16-log</artifactId>
<dependencies>
<!-- Web 场景 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
16.3.1 service/LogLevelDemoService.java ------ 级别与占位符
java
package com.lihaozhe.chapter16.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* 日志演示服务(第16章:五种日志级别的正确分工)
*
* <p>理论知识 ------ Slf4j 门面模式:
* 我们写的代码只认识 slf4j 的 Logger 接口(门面),
* 真正干活的实现(Logback)藏在 classpath 里。
* 好处:哪天想换 Log4j2,业务代码一行不改,换个依赖就行。
*
* <p>理论知识 ------ 五种级别从低到高:
* TRACE < DEBUG < INFO < WARN < ERROR
* 只输出 >= 配置级别 的日志。root 设为 INFO 时,
* log.debug() 会被"过滤"掉(但参数拼接的开销也没有------见占位符写法)。
*/
@Service
public class LogLevelDemoService {
/**
* 每个类声明一个自己的 Logger(静态 final)。
* getLogger(当前类.class) 传入的类名会成为日志的 logger 名,
* 默认打印在行首------控制台每条日志前面的 c.l.c.service.XxxService 就是它。
*/
private static final Logger log = LoggerFactory.getLogger(LogLevelDemoService.class);
/** 演示五种级别 + 占位符用法 */
public String demo(String city, int days) {
// TRACE/DEBUG:开发期排查用。生产(INFO级别)下这两行不会输出
log.trace("trace: 进入了 demo 方法");
log.debug("debug: 收到参数 city={}, days={}", city, days);
// INFO:正常业务里程碑。注意【占位符 {}】而不是字符串拼接------
// 即使级别被过滤,也不会先做 city+days 的拼接浪费 CPU
log.info("info: 用户查询了 {} 的 {} 日游", city, days);
// WARN:能自动恢复但要留意的异常状况
if (days > 30) {
log.warn("warn: 行程天数异常偏大 days={},疑似误输入", days);
}
// ERROR:影响功能的错误。把异常对象作为最后一个参数传入,
// Logback 会自动打印完整堆栈
try {
int price = 100 / (days - days); // 故意除零
} catch (ArithmeticException e) {
log.error("error: 计算价格失败 city={}", city, e);
}
return "日志已按级别输出,请看控制台与 logs/ 目录";
}
}
16.3.2 controller/LogDemoController.java
java
package com.lihaozhe.chapter16.controller;
import com.lihaozhe.chapter16.service.LogLevelDemoService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 演示接口(第16章)
*/
@RestController
@RequestMapping("/ch16")
public class LogDemoController {
private final LogLevelDemoService service;
public LogDemoController(LogLevelDemoService service) {
this.service = service;
}
/** 触发一次五种级别的日志输出 */
@GetMapping("/log")
public String log(@RequestParam(defaultValue = "呼伦贝尔") String city,
@RequestParam(defaultValue = "5") int days) {
return service.demo(city, days);
}
}
16.3.3 resources/logback-spring.xml ------ 本章核心
xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
Logback 独立配置(第16章核心)
理论知识 ------ Spring Boot 怎么找到它?
Boot 默认用内置的 logback 配置;一旦 classpath 根目录出现
logback-spring.xml,就优先用它(-spring 后缀让 Boot 的
<springProfile> 扩展标签可用)。
理论知识 ------ 三大组件:
1. Appender:日志往哪儿写(Console=控制台 / RollingFile=滚动文件)
2. Encoder :日志长什么样(pattern 格式串)
3. Logger/Root:谁记日志、记到什么级别、交给哪些 appender
-->
<configuration>
<!-- 彩色控制台 pattern:
%d 时间 %level 级别 [%thread] 线程名
%logger{36} logger名(最长36字符自动缩写) %msg 内容 %n 换行 -->
<property name="CONSOLE_PATTERN"
value="%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n"/>
<!-- 文件输出 pattern:不需要颜色码 -->
<property name="FILE_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"/>
<!-- 控制台 appender -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 滚动文件 appender:按天滚动 + 单文件超100MB也滚动 + 自动清理30天前 -->
<appender name="ALL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/ch16/all.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- %d 按天切文件,%i 同一天内超过大小后的序号 -->
<fileNamePattern>logs/ch16/all.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize> <!-- 单文件上限,超过滚动出 %i 新文件 -->
<maxHistory>30</maxHistory> <!-- 保留30天历史 -->
<totalSizeCap>2GB</totalSizeCap> <!-- 总占用上限,超出删最老 -->
</rollingPolicy>
<encoder>
<pattern>${FILE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ERROR 单独分家:运维只需要盯这一个文件 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/ch16/error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/ch16/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>60</maxHistory>
</rollingPolicy>
<!-- 过滤器:只放行 ERROR 及以上 -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder>
<pattern>${FILE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- root 日志器:全局默认级别 INFO,同时写三个目的地 -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ALL_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<!-- 单独给本项目开 DEBUG:root 是 INFO,但这个包例外(级别覆盖) -->
<logger name="com.lihaozhe.chapter16" level="DEBUG"/>
</configuration>
16.3.4 TravelApplication.java 与 application.yml
java
package com.lihaozhe.chapter16;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第16章启动类:日志管理
*
* <p>理论知识 ------ 日志体系全景:
* 你的代码 → slf4j-api(门面接口) → Logback(实现) → 控制台/文件
*
* Spring Boot 的 spring-boot-starter-webmvc 已经传递引入了
* slf4j + Logback,所以本章【零额外依赖】。
*
* <p>理论知识 ------ 为什么 System.out.println 该退休?
* 1. 无级别:生产环境没法一键关掉调试输出
* 2. 无格式:没有时间、线程、类名,出问题无法定位
* 3. 无法关闭:println 永远执行;Logger 先判级别再拼串,零浪费
* 4. 不能滚动:控制台输出关机就没;文件日志可按天归档+自动清理
*/
@SpringBootApplication
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
yaml
# 第16章:日志管理
# 端口规则:8080 + 章号 16 → 8116
server:
port: 8116
logging:
# 一旦存在 logback-spring.xml,yml 里的 pattern/console 等大部分失效,
# 但 logging.level 这种"级别覆盖"仍然生效且优先级更高(后应用)。
level:
com.lihaozhe.chapter16: DEBUG
16.4 运行与验证
bash
cd chapter16-log
mvn clean package -DskipTests
java -Dfile.encoding=UTF-8 -jar target/chapter16-log-1.0.0.jar
curl "http://localhost:8116/ch16/log?city=呼伦贝尔&days=45"
→ 日志已按级别输出,请看控制台与 logs/ 目录
验证 1:控制台的彩色分级输出
13:00:22.521 DEBUG [http-nio-8116-exec-2] c.l.c.service.LogLevelDemoService - debug: 收到参数 city=呼伦贝尔, days=45
13:00:22.522 INFO [http-nio-8116-exec-2] c.l.c.service.LogLevelDemoService - info: 用户查询了 呼伦贝尔 的 45 日游
13:00:22.522 WARN [http-nio-8116-exec-2] c.l.c.service.LogLevelDemoService - warn: 行程天数异常偏大 days=45,疑似误输入
13:00:22.522 ERROR [http-nio-8116-exec-2] c.l.c.service.LogLevelDemoService - error: 计算价格失败 city=呼伦贝尔
java.lang.ArithmeticException: / by zero ← 异常对象自动带出完整堆栈
at com.lihaozhe.chapter16.service...
关键观察:
- 没有 trace 行------本项目是 DEBUG 级别,trace 被过滤 ✅
- 占位符
{}正确替换为参数值 ✅ %logger{36}把长包名缩写成c.l.c.service.LogLevelDemoService✅
验证 2:文件分家
请求完成后查看 logs/ch16/ 目录:
all.log 12 KB ← 全量日志(INFO 起,含 DEBUG 因 logger 覆盖)
error.log 10 KB ← 只有 ERROR 及堆栈
grep -c ERROR all.log > 0;而 error.log 中 grep 任何 INFO/WARN 都是 0 条------
过滤器生效,ERROR 已单独分家 ✅ 全部通过。
16.5 小结
| 组件 | 作用 | 本章配置 |
|---|---|---|
| Logger | 每类一个,logger 名=类名 | LoggerFactory.getLogger(Xxx.class) |
| Appender | 输出到哪里 | CONSOLE / ALL_FILE / ERROR_FILE |
| RollingPolicy | 滚动策略 | SizeAndTimeBased:按天+%i 切割 |
| LevelFilter | 只放行某级别 | error.log 只收 ERROR |
<logger> 覆盖 |
包级级别例外 | 本项目包 DEBUG、全局 INFO |
下一章预告:前端同事又来问"这个接口的参数是什么格式?"------口头描述十遍不如一份在线文档。第17章引入 springdoc-openapi:启动应用自动生成 Swagger 文档页面,注解一贴,接口自文档化。
16.7 动手练习
- 给 error.log 加一个"邮件告警"思路验证:查一下 Logback 的 SMTPAppender,写出配置片段(不必真发)。
- 把 all.log 改为按小时滚动(提示:fileNamePattern 的 %d 写成
yyyy-MM-dd_HH)。 - 思考题:为什么
log.debug("x={}", obj)比log.debug("x=" + obj)好?两种写法在 DEBUG 被关闭时各执行了什么?
16.8 本章使用的 Java 25 新特性
| 特性 | 说明 | 本章应用 |
|---|---|---|
| 占位符语法 (Slf4J) | {} 替代字符串拼接,日志级别低于配置时参数不求值、不浪费 CPU |
演示五种级别输出时使用 {} 占位符代替 + 拼接(第16.3.1节) |
| 静态常量 (Java 8+) | private static final Logger log 类级常量,编译期确定、线程安全 |
每个类声明一个 LoggerFactory.getLogger(Xxx.class) 实例(第16.3.1节) |
为什么用现代语法? 日志框架的占位符机制本质上是"延迟求值"------低于当前级别时连参数拼接都跳过,比字符串拼接省 CPU;静态
final常量保证多线程安全且编译期初始化。