Spring Boot 4 旅游主题实战教程 阶段四:缓存与底层进阶

Spring Boot 4 旅游主题实战教程 阶段四:缓存与底层进阶

12 - Redis 入门:给旅游项目装上"内存引擎"

本章目标

  1. 理解 Redis 的定位:内存数据库,10万+ QPS,弥补 MySQL 高频读短板
  2. 掌握 StringRedisTemplate 操作五大基本类型
  3. 旅游场景实战:详情缓存、点赞计数、动态流、收藏去重、热门排行榜
  4. 养成 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 动手练习

  1. 给点赞接口加"每人每天只能赞一次":用 Set 存 ch12:liked:{日期}:{userId},TTL 设为当天剩余秒数。
  2. 实现"附近的人"雏形:把用户加入目的地的 Set,求两个目的地游客的并集(SUNION)。
  3. 思考题:为什么排行榜不用 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.javatopN 方法:tuples.stream().map(t -> ...).toList() 将排行榜结果映射成展示字符串
Map.of() (Java 9+) 简化的不可变 Map 构建方式 RedisDemoService.javajava.util.Map.of("nickname", nickname, "city", city) 构造 Hash 字段
List.of() (Java 9+) 简化的不可变 List 构建方式 RedisDemoService.javatopN 方法:return List.of() 快速返回空列表
Optional/Nullable 处理 (Java 8+) 空值安全处理范式 代码中 quota == null ? 0 : ... 等空值检查,体现现代 Java 的防御性编程意识
ZSetOperations.TypedTuple (Spring Data) Redis 有序集合的类型化元组(Spring 的泛型支持) RedisDemoService.javaSet<ZSetOperations.TypedTuple<String>> 存储带分数的排行榜条目

为什么用现代语法? Stream API 让排行榜结果的转换操作(映射+聚合)一气呵成,比传统 for 循环更简洁;List.of()Map.of() 减少了样板代码;泛型与类型推断让 Redis 操作的类型安全更有保障。这些是 Java 8+ 的标准工具,已成为企业级开发的必用特性。

13 - Spring Cache 缓存实战:把缓存代码"藏"进注解

本章目标

  1. 理解声明式缓存:@Cacheable 三件套如何替代手写的"查缓存→回源→回填"模板
  2. 掌握 RedisCacheManager 自定义:TTL 分级、JSON 序列化、key 前缀
  3. 亲眼验证缓存的威力:2 秒慢查询 → 40 毫秒命中
  4. 理解缓存三大经典事故:穿透、击穿、雪崩,以及本章的防御手段

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 动手练习

  1. findByCity 加上"城市名为空字符串时不缓存"的条件(提示:condition 属性)。
  2. 用 Caffeine 替换 Redis 作为缓存介质:只换依赖和 spring.cache.type,观察业务代码零改动(体会抽象层的价值)。提示:引入 spring-boot-starter-cache 后加 com.github.ben-manes.caffeine:caffeine
  3. 思考题:@CachePut 和"先 @CacheEvict 再等下次查询回填"都能保证缓存最新,各自的风险是什么?(提示:前者若 DB 写失败会怎样?后者删除到回填之间的窗口期呢?)

13.8 本章使用的 Java 25 新特性

特性 说明 本章应用
文本块 (Java 15+) 多行字符串字面量,消除繁琐的换行符拼接 AttractionMapper.java 中 SQL 查询字符串(如 searchByKeyword 的多行 SQL)
var (Java 10+) 局部变量类型推断,代码更简洁 CacheConfig.javavar 用于类型推断(若有的话,实际代码中用了全名)
String.formatted() (Java 15+) 模板字符串格式化,替代 String.format() Attraction.javatoString() 方法中 "Attraction{id=%d, name='%s', city='%s', price=%s}".formatted(...)
LocalDateTime / Duration (Java 8+) 现代日期时间 API,替代 Date/Calendar CacheConfig.javaDuration.ofMinutes(10) 设置缓存 TTL,更直观的类型安全表达
Map.of() (Java 9+) 简化的不可变 Map 构建方式 CacheConfig.javaMap<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 面向切面编程:把日志/耗时逻辑"织"到业务外面

本章目标

  1. 理解 AOP 三大概念:切面、切点、通知;看懂代理对象的调用链
  2. 亲手写出五种通知:@Before / @AfterReturning / @AfterThrowing / @After / @Around
  3. 掌握切点表达式:execution@annotation,以及 @Pointcut 复用
  4. 理解 @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 动手练习

  1. 写一个 @TimeCost 注解切面:任何标注它的方法自动打印耗时,超过 500ms 用 WARN 级别告警。
  2. 把 LogAspect 的切点改为只拦 book 开头的方法(提示:execution(* *..book*(..))@annotation 方案二选一)。
  3. 思考题:为什么环绕通知里 proceed() 抛异常时要重新 throw e 而不能吞掉?结合第11章事务回滚条件想一想。

14.7 本章使用的 Java 25 新特性

特性 说明 本章应用
switch 表达式 (Java 14+) 表达式形式的 switch,支持 -> 箭头语法和返回值 AttractionService.javafindName 方法: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.javajp.getTarget().getClass().getMethod(...) 读取方法信息和注解属性
泛型 (Java 5+) 类型安全的集合与方法参数 AnnotationAspect.javaMethodSignature 类型参数、JoinPoint 的泛型化使用

为什么用现代语法? switch 表达式让多分支逻辑更紧凑、可读性更强,且天然支持返回值;@Audit 自定义注解 + 反射读取的方式是 AOP 注解切面的标准实现,让横切逻辑配置化、声明式;泛型保证了切面代码中类型的安全性。这些特性都是 Java 企业级开发的骨干要素。

15 - 异步与定时任务:慢活别卡住主流程

本章目标

  1. @Async 把耗时操作(发短信/生成报表)扔到后台线程池
  2. 自定义 ThreadPoolTaskExecutor:四参数 + 拒绝策略的正确姿势
  3. 掌握 @Scheduled 定时任务的三种触发方式与 Cron 表达式
  4. 一行配置开启 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 动手练习

  1. sendSms 加上"发送失败重试":用 CompletableFuture 的异常链路,失败后延迟 2 秒重试一次。
  2. 写一个 cron 任务:"每天早上 8 点统计昨日订单量"(cron 表达式写出来即可,业务打印一行日志)。
  3. 思考题:为什么 CallerRunsPolicy 能起到限流作用?如果换成 AbortPolicy(直接抛异常),发短信场景会发生什么?

15.7 本章使用的 Java 25 新特性

特性 说明 本章应用
CompletableFuture (Java 8+) 异步编程的声明式范式,支持链式调用与组合操作 NotificationService.javagenerateReport 方法:return CompletableFuture.completedFuture(report) 异步返回值,调用方可 .get() 获取结果
var (Java 10+) 局部变量类型推断,代码更简洁 TaskController.javavar future = notification.generateReport(city) 自动推断 CompletableFuture 类型
String.formatted() (Java 15+) 模板字符串格式化,替代 String.format() TaskController.javaNotificationService.java 中的日志/响应格式:"已提交后台发送,本接口耗时 %dms".formatted(cost)
虚拟线程 (Java 21+) JVM 级轻量级线程,百万级并发近乎零成本 application.ymlspring.threads.virtual.enabled: true 开启 Tomcat 虚拟线程支持(Java 21+ 正式特性,Java 25 LTS 成熟稳定)
LocalTime / DateTimeFormatter (Java 8+) 现代日期时间 API TravelScheduledTasks.javaLocalTime.now().format(HHMMSS) 格式化定时任务的执行时间
ExecutorService (Java 5+) 线程池抽象与执行框架 AsyncConfig.javaThreadPoolTaskExecutor 自定义线程池,以及虚拟线程 executor 的使用
Stream API (Java 8+) 函数式集合操作 虚拟线程压测示例代码中 Executors.newVirtualThreadPerTaskExecutor() 的并发任务提交模式

为什么用现代语法? CompletableFuture 让异步任务的返回值处理更优雅,避免回调地狱;String.formatted() 让字符串拼接更直观、无需记忆参数位置;虚拟线程是 Java 21+ 的革命性特性,让 IO 密集型应用的并发能力提升一个数量级,而代码改动仅一行配置;LocalTimeDateTimeFormatter 替代了易出错的 Date/Calendar。这些特性都是现代 Java 并发与异步编程的标配。

16 - 日志管理:让 System.out.println 彻底退休

本章目标

  1. 理解 Slf4j 门面 + Logback 实现的两层结构
  2. 掌握五种日志级别的分工与占位符 {} 写法
  3. 编写 logback-spring.xml:按天滚动、超限切割、自动清理
  4. ERROR 日志单独分家,运维只盯一个文件

16.1 上章回顾与本章动机

第15章我们写了大量 log.info(...)。但你有没有想过:

  • 这些日志去哪儿了?只打在控制台,关机就没了!
  • 生产环境怎么按天归档?磁盘满了怎么办?
  • 运维只想看错误,却要在一堆 INFO 里翻?

本章一次性解决。

理论知识 ------ 日志体系全景

复制代码
你的代码 → slf4j-api(门面接口) → Logback(实现) → 控制台/文件

我们写的代码只认识 slf4j 的 Logger 接口(门面),

真正干活的实现(Logback)藏在 classpath 里。

好处:哪天想换 Log4j2,业务代码一行不改。

Spring Boot 的 starter 已经传递引入了 slf4j + Logback,

所以本章零额外依赖------直接用!
理论知识 ------ 为什么 System.out.println 该退休?

  1. 无级别:生产环境没法一键关掉调试输出
  2. 无格式:没有时间、线程、类名,出问题无法定位
  3. 不能关:println 永远执行;Logger 先判级别再拼串,零浪费
  4. 不能滚:文件日志可按天归档 + 自动清理,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 动手练习

  1. 给 error.log 加一个"邮件告警"思路验证:查一下 Logback 的 SMTPAppender,写出配置片段(不必真发)。
  2. 把 all.log 改为按小时滚动(提示:fileNamePattern 的 %d 写成 yyyy-MM-dd_HH)。
  3. 思考题:为什么 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 常量保证多线程安全且编译期初始化。

相关推荐
杨运交40 分钟前
[065][缓存模块]Hibernate二级缓存自定义实现:基于Spring Cache的多级缓存适配器
spring·缓存·hibernate
QQ_21696290962 小时前
【源码编号:project93375】SpringBoot汽车维修管理信息系统:客户车辆、维修预约、工单派发、配件结算全流程实战
java·spring boot·后端·汽车·springboot·需求分析
xcl09252 小时前
流浪宠物领养管理系统开发实战:从需求分析到落地的完整指南
java·spring boot·需求分析·宠物
2601_962063232 小时前
【Spring Boot】掌握 Spring 事务:隔离级别与传播机制解读与应用
spring boot·spring·adb
gis开发之家3 小时前
Spring Boot 4 深度解析,JdbcTemplate 实战——轻量级数据库操作方案
java·数据库·spring boot·后端
李昊哲小课3 小时前
Spring Boot 4 旅游主题实战教程 阶段二:Web 开发基础
前端·spring boot·旅游
上火的金鱼妹4 小时前
redis集群部署与连接
数据库·redis·缓存
2601_962063974 小时前
Spring Boot拦截器(Interceptor)详解
java·spring boot·后端
李昊哲小课4 小时前
SpringBoot4 云端咖啡站 阶段一:起步与基础
spring boot