SpringBoot4 云端咖啡站 阶段四:安全、文件与性能
第 11 章:认证进化 ------ 从 Session 到 JWT
本章目标
- 理解"认证 vs 授权"的区别,搞懂 HTTP 无状态带来的会话难题
- 掌握 BCrypt 密码哈希:为什么绝不能明文存密码
- 亲手实现 JWT 签发/解析/验签(jjwt 0.13 最新 API)
- 用 HandlerInterceptor + ThreadLocal 搭建登录拦截体系
- 分清 Filter 与 Interceptor 的适用场景;记住 ThreadLocal 必须清理的教训
到目前为止任何人都能直接调你的接口------连"你是谁"都不知道。
本章给咖啡站加上会员体系:注册、登录、凭令牌访问个人中心。
知识点讲解
认证与授权
| 回答的问题 | 例子 | |
|---|---|---|
| 认证 Authentication | 你是谁? | 手机号+密码登录 |
| 授权 Authorization | 你能干什么? | 普通会员不能改别人的订单 |
本章做认证;授权思路相同(在拦截器里加角色判断即可)。
会话难题与两种解法
HTTP 协议天生无状态------每个请求都是独立的,服务器不记得上一秒你登录过。

方案 A:Session(有状态)
登录成功 → 服务端内存里存 session[abc123]=用户 → 响应 Set-Cookie: JSESSIONID=abc123
后续请求 → Cookie 自动带上 abc123 → 服务端查表认人
痛点:用户状态存在某一台服务器的内存里。集群部署时要么 Session 复制、要么粘性负载均衡,都很难受。
方案 B:JWT(无状态)------企业主流
登录成功 → 服务端用密钥签名生成令牌发给你(自己存)
后续请求 → Header 里带上 Authorization: Bearer <令牌>
→ 服务端只用密钥验签,不查任何存储 → 集群任意节点都能验证
JWT 三段结构
header.payload.signature(各自 Base64Url 编码,点号分隔):
eyJhbGciOiJIUzM4NCJ9 ← header {"alg":"HS384"}
.eyJzdWIiOiIxIiwiZXhwIjoxNzg3NjI2NDAyfQ ← payload {"sub":"1","exp":...}
.SxH3sQUXHFvPZxSU9iaT... ← signature = HMAC(header+"."+payload, 密钥)
签名是防篡改的核心 :客户端改了 payload 里任何字符(比如把 sub 改成别人 id),重新算不出正确签名,校验立即失败。但注意------payload 只是编码不是加密,不要往里放敏感信息。
BCrypt:密码哈希的事实标准
数据库一旦泄露,明文密码会被拿去撞库所有网站。BCrypt 三大优点:
- 慢:故意设计成约 100ms 一次计算,暴力破解成本极高
- 自带随机盐:同一密码每次注册生成的哈希都不同,防彩虹表
- 自描述 :哈希串内含盐和成本因子,
matches(明文, 哈希)直接比对,无需单独存盐
java
encoder.encode("coffee123") // 注册时:明文 → $2a$10$xxxxx...
encoder.matches("coffee123", hash) // 登录时:比对
依赖 spring-security-crypto 只是个工具包,不会激活 Security 过滤器链------本项目认证完全由自己手写的拦截器实现。
HandlerInterceptor:Spring 的请求关卡
请求 → Filter(Servlet层) → DispatcherServlet → Interceptor.preHandle
→ Controller → postHandle → 视图渲染 → afterCompletion(必走)
| Filter | Interceptor | |
|---|---|---|
| 归属 | Servlet 规范 | Spring MVC |
| 时机 | DispatcherServlet 之前 | Controller 前后 |
| 能否感知要调哪个方法 | 否 | 能 |
| 能否注入 Spring Bean | 麻烦 | 天然支持 |
做认证选 Interceptor 更顺手。三个方法:
preHandle返回 false 直接掐断(本章在这里验令牌)afterCompletion无论成败必走(本章在这里清理 ThreadLocal)
ThreadLocal:线程口袋 + 泄漏陷阱
Tomcat 处理一个请求全程用同一个线程(线程池复用)。ThreadLocal 让每个线程有自己的"口袋变量":
拦截器 preHandle:UserContext.set(user) ← 入口塞入
Controller: UserContext.get() ← 业务随时取,不用层层传参
afterCompletion:UserContext.clear() ← 出口必清!
不清会怎样? 下一个请求复用这个线程时可能读到上一个用户的残留数据------串号事故。所以 clear() 放在 afterCompletion 这个必经之路上。
登录失败提示语的安全考量
"手机号或密码错误"合并提示------如果分开提示"手机号不存在",攻击者就能枚举出哪些手机号注册过(撞库第一步)。小细节,大安全。
完整代码(最终版)
本章共 9 个新文件 + application.yaml 新增 ch11 段。
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 11 章】认证章:数据库配置同前;jwt.* 是本章自定义配置,
# 由 @Value 注入 JwtUtil(密钥生产环境必须外部化并定期轮换)。
# ============================================================
---
spring:
config:
activate:
on-profile: ch11
autoconfigure:
exclude: []
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
idle-timeout: 600000
max-lifetime: 1800000
sql:
init:
mode: never
# JWT 配置(自定义 key,@Value("${jwt.secret}") 绑定)
jwt:
# HS256 签名密钥(至少 32 字符)。教学写死;生产放环境变量/配置中心!
secret: coffee-station-demo-secret-key-2026-please-change-in-prod
# 令牌有效期:2 小时(毫秒)
expire-ms: 7200000
mybatis:
configuration:
# 驼峰映射:列 password_hash ↔ 属性 passwordHash。
# 【真实翻车案例】漏掉这行时登录永远失败------查出的 passwordHash 是 null,
# BCrypt 比对必然不匹配。每个需要数据库的章节段都必须带这条!
map-underscore-to-camel-case: true
【真实翻车案例】首次运行时 ch11 段漏配了 map-underscore-to-camel-case,
注册成功但登录永远失败:MyBatis 把列 password_hash 映射不到属性 passwordHash,
Member 对象里的哈希字段是 null,BCrypt.matches(明文, null) 必然 false。
用 JDBC 直连查库发现哈希完好------问题锁定在映射环节。补上配置立刻恢复。
教训:换 profile 段 = 换了一套 MyBatis 配置,每段都要带全。
src/main/java/com/lihaozhe/chapter11/Member.java
java
package com.lihaozhe.chapter11;
import java.time.LocalDateTime;
/**
* 第 11 章:会员实体(对应 member 表)。
*/
public class Member {
/** 会员 id */
private Long id;
/** 手机号(登录账号) */
private String phone;
/** BCrypt 密码哈希(绝不返回给前端) */
private String passwordHash;
/** 昵称 */
private String nickname;
/** 注册时间(列名 created_at → 驼峰映射) */
private LocalDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}
src/main/java/com/lihaozhe/chapter11/LoginUser.java
java
package com.lihaozhe.chapter11;
/**
* 第 11 章:登录成功后的"当前用户"载体。
*
* <p>只暴露安全字段(id/phone/nickname)------passwordHash 永远不出 Service 层。
* 这个对象会放进 Session 或塞进 ThreadLocal,供业务代码随时取当前用户。</p>
*/
public record LoginUser(Long id, String phone, String nickname) {
}
src/main/java/com/lihaozhe/chapter11/MemberMapper.java
java
package com.lihaozhe.chapter11;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 第 11 章:会员 Mapper ------ 注册/登录的数据支撑。
*/
@Mapper
public interface MemberMapper {
/** 手机号查会员(登录时用;查不到返回 null) */
@Select("""
SELECT id, phone, password_hash, nickname, created_at AS createdAt
FROM member
WHERE phone = #{phone}
""")
Member findByPhone(@Param("phone") String phone);
/** 按 id 查(JWT 解析出 id 后反查用户信息) */
@Select("SELECT id, phone, nickname FROM member WHERE id = #{id}")
Member findById(@Param("id") Long id);
/** 注册:密码存的是 BCrypt 哈希,不是明文! */
@Insert("""
INSERT INTO member (phone, password_hash, nickname)
VALUES (#{phone}, #{passwordHash}, #{nickname})
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(Member member);
}
src/main/java/com/lihaozhe/chapter11/JwtUtil.java
java
package com.lihaozhe.chapter11;
import java.time.Instant;
import java.util.Date;
import javax.crypto.SecretKey;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 第 11 章:JWT 工具 ------ 令牌的签发与解析。
*
* <p>【JWT 三段结构】header.payload.signature(Base64Url 编码,点号分隔)
* header :算法声明 {"alg":"HS256"}
* payload : Claims 载荷 {"sub":"1","nickname":"...","exp":过期时间}
* signature: HMAC-SHA256(header + "." + payload, 密钥)------防篡改的核心!
* 客户端改了 payload 里任何字符,签名校验立即失败。</p>
*
* <p>【无状态的意义】
* 服务端不存任何东西,只拿密钥验签------
* 集群任意节点都能验证,不需要 Session 复制/粘性负载均衡。</p>
*/
@Component
public class JwtUtil {
private final SecretKey key;
/** 令牌有效期(毫秒),从配置注入,默认 2 小时 */
private final long expireMs;
public JwtUtil(@Value("${jwt.secret:coffee-station-demo-secret-key-2026-please-change-in-prod}") String secret,
@Value("${jwt.expire-ms:7200000}") long expireMs) {
// HS256 要求密钥至少 32 字节;教学用固定串,生产环境必须放配置中心且定期轮换
this.key = Keys.hmacShaKeyFor(secret.getBytes(java.nio.charset.StandardCharsets.UTF_8));
this.expireMs = expireMs;
}
/**
* 签发令牌:sub 放会员 id,自定义 claim 放昵称,exp 设过期时间。
*
* <p>【现代时间 API】用 {@link java.time.Instant}(Java 8+)表达时间点,
* 替代遗留的 {@link Date}------Instant 不可变、纳秒精度、无时区歧义。
* JJWT 0.13 的 builder 仍接受 {@code Date},故最后用 {@code Date.from(instant)}
* 在 API 边界做一次转换,业务代码不直接持有 {@code Date} 实例。</p>
*/
public String generate(LoginUser user) {
Instant now = Instant.now();
return Jwts.builder()
.subject(String.valueOf(user.id()))
.claim("nickname", user.nickname())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusMillis(expireMs)))
.signWith(key)
.compact();
}
/**
* 解析并校验令牌:签名不对/已过期 → 抛 JwtException 子类;
* 校验通过返回载荷。这里只取 id 和昵称组装 LoginUser。
*/
public LoginUser parse(String token) {
Claims claims = Jwts.parser()
.verifyWith(key) // 验签 + 过期检查一步完成
.build()
.parseSignedClaims(token)
.getPayload();
return new LoginUser(
Long.valueOf(claims.getSubject()),
null, // payload 不存手机号(减少敏感信息暴露面)
claims.get("nickname", String.class));
}
}
src/main/java/com/lihaozhe/chapter11/UserContext.java
java
package com.lihaozhe.chapter11;
/**
* 第 11 章:ThreadLocal 当前用户上下文。
*
* <p>【ThreadLocal 是什么】
* 每个线程自己的"口袋变量"------Tomcat 处理一个 HTTP 请求全程用同一个线程,
* 所以拦截器在入口塞进去的 LoginUser,Controller/Service 里随时能取,
* 不用一层层传参。</p>
*
* <p>【内存泄漏警告】
* Tomcat 线程池的线程是复用的!用完必须 remove() 清空,
* 否则下一个请求可能读到上一个用户的残留数据(严重事故)。
* 所以拦截器的 afterCompletion(请求结束必经之路)里统一清理。</p>
*/
public final class UserContext {
private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();
private UserContext() {
}
/** 拦截器校验通过后调用 */
public static void set(LoginUser user) {
HOLDER.set(user);
}
/** 业务代码随时取当前登录用户(未登录为 null) */
public static LoginUser get() {
return HOLDER.get();
}
/** 请求结束时必须调用,防止线程复用导致的数据串号 */
public static void clear() {
HOLDER.remove();
}
}
src/main/java/com/lihaozhe/chapter11/AuthInterceptor.java
java
package com.lihaozhe.chapter11;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 第 11 章:登录拦截器 ------ 保护需要登录的接口。
*
* <p>【拦截器 vs 过滤器】
* Filter :Servlet 规范,DispatcherServlet 之前执行,能拦一切请求(含静态资源),
* 但拿不到 Spring 的 Handler 信息;
* Interceptor:Spring MVC 机制,在 Controller 前后执行,能感知"要调哪个方法"、
* 能注入 Spring Bean------做认证选它更顺手。</p>
*
* <p>【执行流程】preHandle(返回 false 直接掐断) → Controller
* → postHandle → afterCompletion(无论成败必走,负责清理 ThreadLocal)。</p>
*/
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
public AuthInterceptor(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 放行浏览器预检请求(跨域场景,无凭据)
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String token = extractToken(request);
if (token == null) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未登录,请先携带令牌\",\"data\":null}");
return false; // 掐断:不再往下走 Controller
}
try {
LoginUser user = jwtUtil.parse(token); // 验签 + 过期检查
UserContext.set(user); // 塞进线程口袋,业务随时取用
return true;
} catch (Exception e) {
// 签名错误 / 令牌过期 都到这来
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"令牌无效或已过期\",\"data\":null}");
return false;
}
}
/** 从 Authorization 头取 Bearer 令牌(HTTP 标准约定) */
private String extractToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring("Bearer ".length());
}
return null;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
UserContext.clear(); // 线程复用防串号,必须清!
}
}
src/main/java/com/lihaozhe/chapter11/WebConfig.java
java
package com.lihaozhe.chapter11;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 第 11 章:Web MVC 配置 ------ 注册拦截器并划定保护范围。
*
* <p>addInterceptors + excludePathPatterns:
* 拦 /api/member/me(查个人信息,必须登录);
* 放行注册/登录接口本身------不然鸡生蛋问题:没登录永远没法登录。</p>
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
public WebConfig(AuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/member/**") // 保护会员相关接口
.excludePathPatterns("/api/member/register", // 注册、登录本身放行
"/api/member/login");
}
}
src/main/java/com/lihaozhe/chapter11/MemberService.java
java
package com.lihaozhe.chapter11;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
/**
* 第 11 章:会员 Service ------ 注册与登录的核心逻辑。
*
* <p>【密码为什么必须哈希存储】
* 数据库一旦泄露,明文密码直接撞库所有网站。
* BCrypt 是"慢哈希":每次计算约 100ms(可调),暴力破解成本极高;
* 且自带随机盐------同一密码每次注册生成的哈希都不同,防彩虹表。</p>
*
* <p>【BCrypt 的验证方式】
* 哈希串自带盐和成本因子,matches(明文, 哈希) 直接比对即可,
* 不需要单独存盐字段。</p>
*/
@Service
public class MemberService {
/** BCrypt 编码器(强度默认 10)。真实项目建议注册为 @Bean 全局复用 */
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
private final MemberMapper memberMapper;
private final JwtUtil jwtUtil;
public MemberService(MemberMapper memberMapper, JwtUtil jwtUtil) {
this.memberMapper = memberMapper;
this.jwtUtil = jwtUtil;
}
/** 注册:手机号查重 + 密码哈希落库 */
public LoginUser register(String phone, String password, String nickname) {
if (memberMapper.findByPhone(phone) != null) {
throw new IllegalArgumentException("该手机号已注册");
}
Member member = new Member();
member.setPhone(phone);
member.setPasswordHash(encoder.encode(password)); // 明文 → BCrypt 哈希
member.setNickname(nickname);
memberMapper.insert(member);
return new LoginUser(member.getId(), phone, nickname);
}
/** 登录响应:record 替代 LinkedHashMap 手拼 JSON */
public record LoginResult(String token, String tokenType, String nickname) {
}
/**
* 登录成功 → 签发 JWT 令牌返回给客户端。
*
* <p>【登录失败提示语的安全考量】
* "手机号或密码错误"合并提示------不告诉攻击者"手机号存在",
* 防止撞库枚举有效账号。</p>
*/
public LoginResult login(String phone, String password) {
Member member = memberMapper.findByPhone(phone);
// 查无此人 / 密码不匹配 统一提示
if (member == null || !encoder.matches(password, member.getPasswordHash())) {
throw new IllegalArgumentException("手机号或密码错误");
}
String token = jwtUtil.generate(new LoginUser(member.getId(), member.getPhone(), member.getNickname()));
return new LoginResult(token, "Bearer", member.getNickname());
}
}
src/main/java/com/lihaozhe/chapter11/MemberController.java
java
package com.lihaozhe.chapter11;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 第 11 章:会员接口 ------ 注册/登录公开,/me 需要令牌。
*/
@RestController
@RequestMapping("/api/member")
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
/** POST /api/member/register {"phone","password","nickname"} */
public record RegisterRequest(String phone, String password, String nickname) {
}
/** POST /api/member/login {"phone","password"} */
public record LoginRequest(String phone, String password) {
}
/** 注册/个人中心响应:record 组件名即 JSON 字段名(替代 LinkedHashMap 手拼) */
public record MemberView(Long id, String nickname, String message) {
}
@PostMapping("/register")
public MemberView register(@RequestBody RegisterRequest request) {
LoginUser user = memberService.register(
request.phone(), request.password(), request.nickname());
return new MemberView(user.id(), user.nickname(), null);
}
@PostMapping("/login")
public Object login(@RequestBody LoginRequest request) {
return memberService.login(request.phone(), request.password());
}
/**
* GET /api/member/me ------ 被拦截器保护的个人中心。
* 能进到这个方法说明令牌已验证通过,直接从 ThreadLocal 取当前用户。
*/
@GetMapping("/me")
public MemberView me() {
LoginUser user = UserContext.get();
return new MemberView(user.id(), user.nickname(), "这是只有登录用户才能看到的个人中心");
}
}
src/main/java/com/lihaozhe/chapter11/SimpleExceptionHandler.java
java
package com.lihaozhe.chapter11;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 第 11 章:本章简易异常处理 ------ 把业务失败翻译成 400 + JSON。
*
* <p>第 09 章的 GlobalExceptionHandler 在别的包里,且本章为了聚焦认证
* 没有引入 Result/ErrorCode 体系,所以配一个最小版:
* 只处理 IllegalArgumentException,够用即可。</p>
*/
@RestControllerAdvice
public class SimpleExceptionHandler {
/**
* 错误响应载体(record 替代 LinkedHashMap 手拼):
* record 组件名即 JSON 字段名({@code code}/{@code message}/{@code data}),
* Jackson 自动调用 record 的访问器方法完成序列化。
*/
public record ErrorView(int code, String message, Object data) {
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorView handleIllegalArgument(IllegalArgumentException e) {
return new ErrorView(400, e.getMessage(), null);
}
}
src/main/java/com/lihaozhe/chapter11/CoffeeApplication11.java
java
package com.lihaozhe.chapter11;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 11 章:认证章启动类。
*
* <p>spring-security-crypto 只提供 BCrypt 工具类(不会激活 Security 过滤器链),
* 认证逻辑完全由本章手写的拦截器 + JWT 实现------看得见摸得着。</p>
*/
@SpringBootApplication
public class CoffeeApplication11 {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CoffeeApplication11.class);
app.setAdditionalProfiles("ch11");
app.run(args);
}
}
运行验证
第 1 步:编译启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter11.CoffeeApplication11
第 2 步:场景一 ------ 裸奔访问被拦
bash
curl -w "\nHTTP_STATUS=%{http_code}\n" http://localhost:8080/api/member/me
预期:
json
{"code":401,"message":"未登录,请先携带令牌","data":null}
HTTP_STATUS=401
第 3 步:场景二 ------ 注册 → 错误密码 → 正确登录
bash
printf '{"phone":"13800001111","password":"coffee123","nickname":"\xe5\x92\x96\xe5\x8f\x8b\xe5\xb0\x8f\xe6\x9d\x8e"}' > reg.json
curl -X POST http://localhost:8080/api/member/register -H "Content-Type: application/json" --data-binary @reg.json
预期:{"id":1,"nickname":"咖友小李"}。再注册一次会得到 400"该手机号已注册"。
bash
printf '{"phone":"13800001111","password":"wrongpwd"}' > badlogin.json
curl -w "\nHTTP_STATUS=%{http_code}\n" -X POST http://localhost:8080/api/member/login \
-H "Content-Type: application/json" --data-binary @badlogin.json
预期(注意统一提示,没说"手机号存在"):
json
{"code":400,"message":"手机号或密码错误","data":null}
HTTP_STATUS=400
bash
curl -X POST http://localhost:8080/api/member/login \
-H "Content-Type: application/json" --data-binary @reg.json > login_resp.json
cat login_resp.json
预期拿到三件套:
json
{"token":"eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiIxIi...","tokenType":"Bearer","nickname":"咖友小李"}
第 4 步:场景三 ------ 凭令牌进入个人中心
bash
TOKEN=$(sed 's/.*"token":"\([^"]*\)".*/\1/' login_resp.json)
curl -w "\nHTTP_STATUS=%{http_code}\n" http://localhost:8080/api/member/me \
-H "Authorization: Bearer $TOKEN"
预期:
json
{"id":1,"nickname":"咖友小李","message":"这是只有登录用户才能看到的个人中心"}
HTTP_STATUS=200
第 5 步:场景四 ------ 篡改令牌被拒(验签生效)
bash
TAMPERED="${TOKEN%?}X"
curl -w "\nHTTP_STATUS=%{http_code}\n" http://localhost:8080/api/member/me \
-H "Authorization: Bearer $TAMPERED"
只改最后一个字符,签名立即失效:
json
{"code":401,"message":"令牌无效或已过期","data":null}
HTTP_STATUS=401
(验证完记得删掉测试会员数据。)
常见坑
| 现象 | 原因与解决 |
|---|---|
| 注册成功但登录永远失败 | 驼峰映射没开,passwordHash 是 null(真实翻车案例见上文);检查 map-underscore-to-camel-case |
| WeakKeyException 密钥太短 | HS256 要求 ≥32 字节;测试密钥也要够长 |
| 令牌过期异常类型记不住 | jjwt 统一抛 JwtException 子类(ExpiredJwtException 等),catch Exception 兜住即可 |
| ThreadLocal 数据串号 | afterCompletion 里忘了 clear();线程复用读到上个用户 |
| payload 存了敏感信息 | payload 仅编码非加密,Base64 解码即可读;只放 id/nickname 等非敏感 claim |
| 登录接口也被拦截器拦了 | excludePathPatterns 忘了放行 register/login |
| 明文存密码 | 数据库泄露即撞库灾难;必须 BCrypt 等慢哈希 |
自测题
- 认证和授权分别回答什么问题?
- Session 方案在集群部署时的两大痛点是什么?JWT 怎么解决?
- JWT 的三段各是什么?哪一段保证防篡改?payload 加密吗?
- 为什么同一密码每次注册生成的 BCrypt 哈希都不一样?matches 怎么还能比对?
- Interceptor 相比 Filter 做认证的两个优势是什么?preHandle 返回 false 意味着什么?
- ThreadLocal 用完不清除会导致什么事故?本章把 clear() 放在了哪个方法里?
下一章预告
菜单表里有个 image 字段一直空着------咖啡没有图总差点意思。
下一章学习文件上传下载:MultipartFile 接收图片、限制大小与类型、存到磁盘并把访问路径回填到菜单表,最后配上静态资源映射让浏览器能直接看到上传的咖啡照片。
第 12 章:文件上传与下载 ------ 给咖啡配上照片
本章目标
- 掌握 MultipartFile:接收、校验、落盘的完整流程
- 记住文件上传的安全三板斧(白名单类型 / 大小限制 / UUID 重命名)
- 理解"数据库存路径、磁盘存内容"的业界标准做法
- 配置 spring.servlet.multipart 大小限制 + MaxUploadSizeExceededException 处理
- 用 addResourceHandlers 做静态资源映射,浏览器直接看到上传的图片
菜单表里的 image 字段空了八章------咖啡没有图总差点意思。本章补上:上传照片 → 存磁盘 → 路径回填数据库 → URL 直接访问。
知识点讲解

文件上传的完整链路
浏览器表单 (multipart/form-data 编码)
│ curl 对应 -F 参数
▼
内嵌 Tomcat 解析 multipart 流
│ 超过 max-file-size 直接抛 MaxUploadSizeExceededException
▼
Spring 封装成 MultipartFile 注入 Controller
│ @RequestParam("file") MultipartFile file
▼
Service 校验 + 重命名 + 落盘
▼
数据库只存 URL 路径,磁盘存二进制内容
MultipartFile 常用方法:
| 方法 | 作用 |
|---|---|
| getOriginalFilename() | 用户原始文件名(不可信,只能用来取扩展名) |
| getSize() | 字节数 |
| isEmpty() | 是否没传文件 |
| getInputStream() | 读内容流 |
安全三板斧(缺一不可)
- 类型白名单:只放行 jpg/jpeg/png/webp。防上传 .jsp/.exe 等可执行文件被服务器解析或下载执行
- 大小限制:yaml 里 max-file-size: 2MB。防磁盘被恶意塞爆(DoS)
- UUID 重命名 :
UUID.randomUUID().toString().replace("-","") + ".jpg"。
第三条最容易被忽视也最致命------绝不能拿用户提供的文件名拼路径:
攻击者上传文件名为 "../../evil.jsp"
直接 resolve → 逃出上传目录覆盖任意文件(路径穿越 Path Traversal)
UUID 是服务端随机生成的,彻底摆脱用户输入的影响。
数据库存路径,不存内容
把图片二进制塞进数据库(BLOB)的问题:
- 备份体积暴涨、查询变慢
- 数据库连接被大字段传输长时间占用
- 无法利用 CDN/缓存
标准做法:内容存磁盘(生产环境是对象存储 OSS/S3),数据库只存访问 URL(如 /images/xxx.jpg)。
静态资源映射
Spring Boot 默认只认 classpath:/static 等内置目录;运行期上传的文件在 jar 外的磁盘上,必须手动映射:
java
registry.addResourceHandler("/images/**") // URL 前缀
.addResourceLocations(Paths.get(uploadDir) // 物理目录
.toAbsolutePath().toUri().toString()); // file:///D:/... 形式,结尾斜杠必须保留
之后浏览器 GET /images/abc.png 就能直接看到图片------这就是"下载/展示"的实现(图片场景不需要写专门的下载接口)。
大小超限的特殊性
MaxUploadSizeExceededException 在请求进入 Controller 之前就抛出(multipart 解析阶段),普通业务异常处理接不到它,必须单独 @ExceptionHandler 并返回 413(Payload Too Large)。
完整代码(最终版)
本章共 6 个新文件 + application.yaml 新增 ch12 段。
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 12 章】文件上传章:multipart 限制 + 上传目录配置。
# ============================================================
---
spring:
config:
activate:
on-profile: ch12
autoconfigure:
exclude: []
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
idle-timeout: 600000
max-lifetime: 1800000
sql:
init:
mode: never
# 文件上传限制(Spring Boot 内嵌 Tomcat 的 multipart 配置):
servlet:
multipart:
# 单文件上限 2MB------超限抛 MaxUploadSizeExceededException(本章有专门 handler)
max-file-size: 2MB
# 单次请求总上限(多文件场景)
max-request-size: 5MB
# 自定义上传配置(@Value 注入 UploadService/WebConfig)
app:
# 磁盘存储目录(相对项目根;生产环境用绝对路径或对象存储)
upload-dir: uploads
# 对外访问 URL 前缀(与 WebConfig 静态资源映射一致)
upload-url-prefix: /images/
mybatis:
configuration:
map-underscore-to-camel-case: true
src/main/java/com/lihaozhe/chapter12/Coffee.java
java
package com.lihaozhe.chapter12;
import java.math.BigDecimal;
/**
* 第 12 章:咖啡实体(同前几章结构;image 本章正式启用)。
*/
public class Coffee {
/** 商品编号 */
private Long id;
/** 饮品名称 */
private String name;
/** 价格 */
private BigDecimal price;
/** 风味简介 */
private String description;
/** 图片访问路径(如 /images/xxxx.jpg) */
private String image;
/** 库存杯数 */
private Integer stock;
public Coffee() {
}
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 BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
}
src/main/java/com/lihaozhe/chapter12/CoffeeMapper.java
java
package com.lihaozhe.chapter12;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 第 12 章:菜单 Mapper ------ 上传成功后把图片路径回填到 image 列。
*/
@Mapper
public interface CoffeeMapper {
@Select("""
SELECT id, name, price, description, image, stock
FROM coffee_menu
WHERE id = #{id}
""")
Coffee findById(@Param("id") Long id);
@Select("SELECT id, name, price, description, image, stock FROM coffee_menu ORDER BY id")
java.util.List<Coffee> findAll();
/** 上传成功后更新 image 字段(存的是 URL 路径,不是图片内容!) */
@Update("UPDATE coffee_menu SET image = #{image} WHERE id = #{id}")
int updateImage(@Param("id") Long id, @Param("image") String image);
}
src/main/java/com/lihaozhe/chapter12/UploadService.java
java
package com.lihaozhe.chapter12;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* 第 12 章:文件上传 Service ------ 校验、重命名、落盘三步走。
*
* <p>【文件上传的安全三板斧】
* ① 验类型:只放行白名单后缀(jpg/png/webp)------防上传 .jsp/.exe 等可执行文件
* ② 验大小:spring.servlet.multipart.max-file-size 已在 yaml 限 2MB------防磁盘被塞爆
* ③ 改名字:UUID 重命名 + 固定扩展名拼接------
* 绝不使用用户提供的文件名!攻击者可以构造 "../../evil.jsp" 这类
* 路径穿越(Path Traversal)或覆盖系统文件的恶意名称。</p>
*
* <p>【存数据库的是路径不是内容】
* 图片二进制存磁盘(或对象存储),数据库只存访问 URL------
* 这是业界标准做法;把大文件塞进数据库会让备份膨胀、查询变慢。</p>
*/
@Service
public class UploadService {
/** 允许的图片扩展名白名单 */
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "webp");
/** 上传目录:从配置注入,默认项目下 uploads/ */
private final Path uploadDir;
/** 对外访问的 URL 前缀(与静态资源映射保持一致) */
private final String urlPrefix;
public UploadService(@Value("${app.upload-dir:uploads}") String uploadDir,
@Value("${app.upload-url-prefix:/images/}") String urlPrefix) {
this.uploadDir = Paths.get(uploadDir);
this.urlPrefix = urlPrefix;
// 目录不存在就创建(IOException 的 checked 包裹是 Files API 的常规姿势)
try {
Files.createDirectories(this.uploadDir);
} catch (IOException e) {
throw new IllegalStateException("无法创建上传目录: " + uploadDir, e);
}
}
/**
* 保存上传文件,返回可直接访问的 URL 路径。
*
* @param menuId 关联的菜单 id(校验存在性由 Controller 完成)
* @param file 浏览器传来的文件(MultipartFile 是 Spring 对上传件的抽象)
* @return 如 /images/3f2a...c1.jpg
*/
public String save(Long menuId, MultipartFile file) {
// ① 空文件直接拒绝
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("请选择要上传的图片");
}
// ② 类型白名单校验(取原始文件名的扩展名做判断)
String originalName = file.getOriginalFilename(); // 用户原始文件名,只用来取后缀
String ext = getExtension(originalName);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("仅支持图片格式: " + ALLOWED_EXT);
}
// ③ UUID 重命名:彻底摆脱用户输入的影响
// 拼上白名单内的固定扩展名
String newName = java.util.UUID.randomUUID().toString().replace("-", "") + "." + ext;
// ④ 落盘:copy+REPLACE_EXISTING(理论上 UUID 不冲突,防御性写法而已)
Path target = uploadDir.resolve(newName); // resolve = 拼路径,自动处理分隔符
try (var in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new IllegalStateException("保存文件失败", e);
}
return urlPrefix + newName; // 返回对外 URL
}
/** 取小写扩展名;无后缀返回空串 */
private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return "";
}
return filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
}
}
src/main/java/com/lihaozhe/chapter12/WebConfig.java
java
package com.lihaozhe.chapter12;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 第 12 章:静态资源映射 ------ 让磁盘上的 uploads/ 目录能通过 /images/** 访问。
*
* <p>Spring Boot 默认只认 classpath:/static、/public 等内置目录;
* 运行期上传的文件在 jar 外面的磁盘上,必须手动把 URL 前缀映射到物理目录。</p>
*
* <p>addResourceHandlers 三要素:
* URL 模式(/images/**)→ 物理位置(file: 协议 + 绝对路径)→ 浏览器直接 GET 即可看到图片。</p>
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final String uploadDir;
public WebConfig(@org.springframework.beans.factory.annotation.Value("${app.upload-dir:uploads}") String uploadDir) {
this.uploadDir = uploadDir;
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// toUri() 会输出 file:///D:/.../uploads/ 形式,结尾斜杠必须保留
registry.addResourceHandler("/images/**")
.addResourceLocations(java.nio.file.Paths.get(uploadDir).toAbsolutePath().toUri().toString());
}
}
src/main/java/com/lihaozhe/chapter12/MenuController.java
java
package com.lihaozhe.chapter12;
import java.util.LinkedHashMap;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 第 12 章:上传接口 + 菜单查询。
*
* <p>【MultipartFile 三要素】
* 浏览器表单/工具必须用 multipart/form-data 编码(curl 用 -F);
* @RequestParam MultipartFile 接收;getOriginalFilename/getSize/getInputStream 取内容。</p>
*/
@RestController
public class MenuController {
private final UploadService uploadService;
private final CoffeeMapper coffeeMapper;
public MenuController(UploadService uploadService, CoffeeMapper coffeeMapper) {
this.uploadService = uploadService;
this.coffeeMapper = coffeeMapper;
}
/**
* POST /api/menu/{id}/image ------ 给某饮品上传照片。
* 成功后 image 字段回填 URL,菜单列表立刻能看到图片地址。
*/
@PostMapping("/api/menu/{id}/image")
public Object upload(@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
if (coffeeMapper.findById(id) == null) {
throw new IllegalArgumentException("咖啡不存在: id=" + id);
}
String url = uploadService.save(id, file);
coffeeMapper.updateImage(id, url); // 路径落库
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
result.put("menuId", id);
result.put("imageUrl", url);
return result;
}
/** GET /api/menu/{id} ------ 查看含 image 的菜单详情 */
@GetMapping("/api/menu/{id}")
public Coffee detail(@PathVariable Long id) {
Coffee coffee = coffeeMapper.findById(id);
if (coffee == null) {
throw new IllegalArgumentException("咖啡不存在: id=" + id);
}
return coffee;
}
}
src/main/java/com/lihaozhe/chapter12/SimpleExceptionHandler.java
java
package com.lihaozhe.chapter12;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
/**
* 第 12 章:本章异常处理 ------ 业务错误 + 文件超限。
*
* <p>MaxUploadSizeExceededException 是 Spring 在请求进入 Controller 之前
* 就抛出的(multipart 解析阶段),必须单独接住,否则客户端看到的是
* Tomcat 默认连接重置页。</p>
*/
@RestControllerAdvice
public class SimpleExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
public Object handleTooLarge(MaxUploadSizeExceededException e) {
return new java.util.LinkedHashMap<String, Object>() {{
put("code", 413);
put("message", "文件超过大小限制(最大 2MB)");
put("data", null);
}};
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Object handleIllegalArgument(IllegalArgumentException e) {
return new java.util.LinkedHashMap<String, Object>() {{
put("code", 400);
put("message", e.getMessage());
put("data", null);
}};
}
}
src/main/java/com/lihaozhe/chapter12/CoffeeApplication12.java
java
package com.lihaozhe.chapter12;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 12 章:文件上传章启动类。
*/
@SpringBootApplication
public class CoffeeApplication12 {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CoffeeApplication12.class);
app.setAdditionalProfiles("ch12");
app.run(args);
}
}
运行验证
第 1 步:编译启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter12.CoffeeApplication12
准备一张测试图片(任意 png/jpg 都行,这里生成一个最小 PNG):
bash
printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xf8\xcf\xc0\xf0\x1f\x00\x05\x05\x02\x00_\xc8\xf1\xd2\x00\x00\x00\x00IEND\xaeB`\x82' > coffee.png
第 2 步:场景一 ------ 上传合法图片
bash
curl -X POST http://localhost:8080/api/menu/1/image -F "file=@coffee.png"
预期(UUID 重命名生效,返回可直接访问的 URL):
json
{"menuId":1,"imageUrl":"/images/1c55756d6bc248459ff35025336abd49.png"}
同时项目根下多了 uploads/1c55756d....png 文件。
第 3 步:场景二 ------ 静态资源直接访问(下载/展示)
bash
curl -o /dev/null -w "HTTP_STATUS=%{content_type} SIZE=%{size_download}\n" \
http://localhost:8080/images/1c55756d6bc248459ff35025336abd49.png
预期:HTTP_STATUS=image/png SIZE=70------浏览器打开这个 URL 能直接看到图。
第 4 步:场景三 ------ 路径已回填数据库
bash
curl http://localhost:8080/api/menu/1
预期 image 字段不再是 null:
json
{"description":"浓缩咖啡与蒸汽牛奶的经典组合","id":1,"image":"/images/1c55756d....png","name":"拿铁","price":28.00,"stock":98}
第 5 步:场景四 ------ 安全防线三连
bash
# ① 非法类型
echo "not an image" > evil.txt
curl -w "\nHTTP_STATUS=%{http_code}\n" -X POST http://localhost:8080/api/menu/1/image -F "file=@evil.txt"
预期:400 + 仅支持图片格式: [jpeg, jpg, png, webp]。
bash
# ② 超过 2MB
dd if=/dev/zero of=big.png bs=1M count=3
curl -w "\nHTTP_STATUS=%{http_code}\n" -X POST http://localhost:8080/api/menu/1/image -F "file=@big.png"
预期:413 + 文件超过大小限制(最大 2MB)。
bash
# ③ 不存在的菜单
curl -s -o /dev/null -w "HTTP_STATUS=%{http_code}\n" -X POST http://localhost:8080/api/menu/999/image -F "file=@coffee.png"
预期:400。
(验证完删除测试图片并还原 image 字段为 NULL。)
常见坑
| 现象 | 原因与解决 |
|---|---|
| 上传接口报 415/400 | 客户端没用 multipart/form-data;curl 要用 -F 不是 -d |
| 文件太大直接连接重置 | 没处理 MaxUploadSizeExceededException;单独 @ExceptionHandler 返回 413 |
| 静态资源 404 | toUri() 结果丢了结尾斜杠;或 URL 前缀与映射不一致 |
| 用原始文件名保存被注入路径 | 路径穿越攻击;必须 UUID 重命名 |
| 重启后图片丢失 | 上传到了临时目录;upload-dir 用明确的持久化路径 |
| 多实例部署图片不一致 | 本地磁盘方案的天花板;生产改对象存储(OSS/S3)+ 全局唯一 URL |
自测题
- 文件上传安全三板斧是哪三条?各自防御什么攻击?
- 为什么绝对不能用 getOriginalFilename() 的值拼存储路径?
- 数据库里 image 列存的是什么?为什么不像 BLOB 那样存内容?
- MaxUploadSizeExceededException 为什么需要单独的 handler?它的 HTTP 状态码应该用什么?
- addResourceHandlers 把什么映射到了什么?toUri() 输出的形式长什么样?
- 本地磁盘存上传文件在集群部署时有什么问题?业界的解法是什么?
下一章预告
每次查询菜单都要跑一趟数据库------数据明明没变,却反复付连接开销。下一章学习 Spring Cache 缓存抽象:@Cacheable/@CachePut/@CacheEvict 三个注解给菜单查询加上内存缓存,顺便搞懂缓存穿透、雪崩两大经典问题与对策。
第 13 章:Spring Cache 缓存 ------ 让菜单查询快到没朋友
本章目标
- 掌握 @EnableCaching 开关 + @Cacheable/@CachePut/@CacheEvict 三板斧
- 理解缓存注解的 AOP 本质与"自调用绕过缓存"坑
- 认识 ConcurrentMapCacheManager 的能力边界(内存、无 TTL)
- 通过库存对照实验理解"什么数据适合缓存、什么不适合"
- 了解缓存穿透、雪崩、击穿三大经典问题与对策
菜单详情页是全站访问量最大的接口,但每次都要跑一趟 MySQL------数据明明几天才改一次。本章用三个注解给查询提速,并搞清楚缓存的代价。
知识点讲解

Spring Cache 是一层抽象,不是具体缓存
你的代码 只面向注解(@Cacheable...)和 CacheManager 接口
│
Spring AOP 代理 拦截方法调用 → 查缓存 → 决定是否放行真实方法
│
CacheManager 缓存管理器 SPI
├── ConcurrentMapCacheManager(本章:内存 Map,零依赖)
└── RedisCacheManager(生产:Redis 集群,加依赖+配置即可切换)
业务代码一行不改就能从内存缓存升级到 Redis------这就是"面向抽象编程"的红利,和第 04 章 JdbcTemplate 换连接池是一个思想。
三个注解的语义(背下来)
| 注解 | 方法执行? | 用途 | 典型场景 |
|---|---|---|---|
| @Cacheable | 命中时不执行 | 读加速 | 菜单详情 |
| @CachePut | 一定执行 | 写后同步 | 改价格后刷新缓存 |
| @CacheEvict | 默认执行 | 删条目 | 删除数据/清场 |
@CacheEvict 两个常用参数:
key = "#id":只删某一条allEntries = true:清空整块缓存空间(批量更新后的"一键清场")
key 与 SpEL
@Cacheable(value = "menu", key = "#id"):
value/cacheNames:缓存空间名(类似 Redis 的 key 前缀,一应用可分多柜)key:SpEL 表达式。#参数名取参数值;#result引用返回值(@CachePut 常用)
最终存储结构 ≈ ConcurrentHashMap<String, Object>:menu 柜里 {1=Coffee@xx, 3=Coffee@yy}。
不写 key 时默认用 SimpleKey.EMPTY------无参方法专用;多参方法必须显式指定或用组合 key。
底层就是 AOP
三个注解由 CacheInterceptor(一个 AOP 拦截器)实现,Spring 给 MenuService 生成代理对象。所以第 10 章的坑在这里原样复现:
java
// 同类内部调用 this.getById(1L) ------ 不走代理 → 不走缓存!
public Coffee getByIdWithLog(Long id) {
return this.getById(id); // ❌ 每次都会真实查库
}
解法同第 10 章:拆到另一个 Bean,或者注入自身代理。
ConcurrentMapCacheManager 的边界
| 特性 | 表现 |
|---|---|
| 存储 | JVM 内存 ConcurrentHashMap |
| 重启 | 缓存全部丢失 |
| TTL 过期时间 | 没有!数据不变就永远留在内存 |
| 容量上限 | 没有,可能 OOM |
| 多实例共享 | 不支持(每台 JVM 各存一份) |
教学够用;生产必换 Redis(自带 TTL/容量淘汰/集群共享)。
什么数据适合缓存?(对照实验结论)
判断标准三条:读多写少 + 允许短暂不一致 + 数据有稳定主键。
- ✅ 菜单详情/列表:一天改不了几次
- ❌ 库存:每单都在变,缓存里的永远是旧值(本章实验:扣完 2 杯,缓存视角还是 98)
- ❌ 余额、订单状态:一致性要求高的字段宁可每次查库
缓存三大经典问题(面试高频)
| 问题 | 定义 | 对策 |
|---|---|---|
| 穿透 | 查询不存在的数据,缓存永不命中,请求全打到库 | 缓存空值(null 也存,短 TTL);布隆过滤器 |
| 雪崩 | 大批 key 同时过期,数据库瞬间被压垮 | 过期时间加随机抖动;热点永不过期+异步刷新 |
| 击穿 | 单个热点 key 过期瞬间,海量请求直击数据库 | 互斥锁(只放一个请求回源);逻辑过期 |
注意区分:穿透是"查没有的数据",击穿是"查有的但刚过期的数据",雪崩是"很多 key 一起出事"。ConcurrentMapCacheManager 没有 TTL 所以这三个问题都不触发------这也是它"安全"的一面,切 Redis 后才会遇到。
完整代码(最终版)
本章共 6 个新文件 + pom 新增 starter + application.yaml 新增 ch13 段。
pom.xml(新增部分)
xml
<!-- 【第 13 章】Spring Cache 缓存抽象:
提供 @Cacheable/@CachePut/@CacheEvict 注解与缓存管理器接口。
本章用最简单的 ConcurrentMapCacheManager(内存 Map 缓存);
生产环境换 RedisCacheManager 只需换依赖+配置,业务代码零改动。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 13 章】缓存章:数据库配置同前。
# 缓存本身零配置------@EnableCaching + 类路径上的 cache starter
# 自动装配 ConcurrentMapCacheManager(内存 Map 缓存)。
# 生产换 Redis:加 spring-boot-starter-data-redis 并配
# spring.data.redis.* 即可,业务注解代码一行不改(面向抽象编程的红利)。
# ============================================================
---
spring:
config:
activate:
on-profile: ch13
autoconfigure:
exclude: []
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
idle-timeout: 600000
max-lifetime: 1800000
sql:
init:
mode: never
mybatis:
configuration:
map-underscore-to-camel-case: true
src/main/java/com/lihaozhe/chapter13/Coffee.java
java
package com.lihaozhe.chapter13;
import java.math.BigDecimal;
/**
* 第 13 章:咖啡实体(与前几章同构;缓存的是这个对象的实例)。
*
* <p>【缓存对象的要求】
* 被缓存的对象最好设计成"不可变思维"使用------放进缓存后就不要再修改它,
* 否则下次从缓存拿到的对象是"被污染"的。ConcurrentMapCacheManager
* 存的就是对象引用,改它等于改缓存。</p>
*/
public class Coffee {
/** 商品编号 */
private Long id;
/** 饮品名称 */
private String name;
/** 价格 */
private BigDecimal price;
/** 风味简介 */
private String description;
/** 图片路径(本章不启用,保持与库表结构对齐) */
private String image;
/** 库存杯数 */
private Integer stock;
public Coffee() {
}
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 BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
}
src/main/java/com/lihaozhe/chapter13/CoffeeMapper.java
java
package com.lihaozhe.chapter13;
import java.math.BigDecimal;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 第 13 章:菜单 Mapper。
*
* <p>缓存章的关键认知:Mapper/DAO 层【不加任何缓存注解】------
* 缓存是业务语义("这个菜单数据不常变"),属于 Service 层的职责;
* DAO 只管老老实实读写数据库。</p>
*/
@Mapper
public interface CoffeeMapper {
@Select("""
SELECT id, name, price, description, image, stock
FROM coffee_menu
WHERE id = #{id}
""")
Coffee findById(@Param("id") Long id);
@Select("SELECT id, name, price, description, image, stock FROM coffee_menu ORDER BY id")
List<Coffee> findAll();
/** 改价格(演示 @CachePut:改完数据库立刻刷新缓存) */
@Update("UPDATE coffee_menu SET price = #{price} WHERE id = #{id}")
int updatePrice(@Param("id") Long id, @Param("price") BigDecimal price);
/** 改库存(演示"为什么库存不适合缓存"------高频写场景) */
@Update("UPDATE coffee_menu SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty}")
int deductStock(@Param("id") Long id, @Param("qty") Integer qty);
}
src/main/java/com/lihaozhe/chapter13/MenuService.java
java
package com.lihaozhe.chapter13;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/**
* 第 13 章:菜单 Service ------ 三个缓存注解的完整演示。
*
* <p>【Spring Cache 三板斧】
* @Cacheable 读前先查缓存,命中就【不执行方法】;未命中执行后把返回值放入缓存
* @CachePut 无论缓存有没有,方法一定执行,返回值刷新进缓存(用于写后同步)
* @CacheEvict 方法执行后(或前)删除缓存条目(用于删除/清场)
*
* <p>【底层原理】三个注解都是 AOP 实现的------Spring 给 MenuService 生成代理,
* 调用先经过 CacheInterceptor:查缓存 → 决定要不要放行真实方法。
* 所以第 10 章讲过的"自调用绕过代理"坑在这里同样存在:
* 本类内部 this.getById() 调用不会走缓存!</p>
*/
@Service
public class MenuService {
private final CoffeeMapper coffeeMapper;
public MenuService(CoffeeMapper coffeeMapper) {
this.coffeeMapper = coffeeMapper;
}
/**
* 按 id 查菜单 ------ @Cacheable 主战场。
*
* @param value/cacheNames 缓存空间名(一个应用可有多块独立缓存,类似"分柜")
* @param key 缓存键。#id 是 SpEL 表达式,取方法参数 id 的值
* 最终缓存结构 ≈ menu[13] 这个 Map 里存了 {1=Coffee@xx, 3=Coffee@yy}
* @return 命中缓存时直接返回缓存对象,数据库一次都不碰
*/
@Cacheable(value = "menu", key = "#id")
public Coffee getById(Long id) {
System.out.println(">>> 【查库】getById(" + id + ") 执行了真实 SQL ------ 这行打印说明缓存未命中");
return coffeeMapper.findById(id);
}
/**
* 查全部菜单 ------ 第二块缓存空间 "menuList"。
* 注意 List 整体作为一条缓存记录(key 不填时默认 SimpleKey.EMPTY)。
*/
@Cacheable("menuList")
public List<Coffee> getAll() {
System.out.println(">>> 【查库】getAll() 执行了真实 SQL");
return coffeeMapper.findAll();
}
/**
* 改价格 ------ @CachePut 演示:写操作必须让缓存跟着变。
*
* <p>如果这里用 @Cacheable 就大错特错:改价格方法永远不该被"跳过";
* 如果什么都不加也不对:库里改了、缓存里还是旧价格,读到脏数据。
* @CachePut 的语义是"方法照常执行 + 返回值覆盖缓存",完美贴合"写后同步"。</p>
*/
@CachePut(value = "menu", key = "#result.id") // #result 引用方法返回值
public Coffee updatePrice(Long id, BigDecimal price) {
coffeeMapper.updatePrice(id, price);
Coffee fresh = coffeeMapper.findById(id);
System.out.println(">>> 【写库+刷缓存】价格已更新为 " + price + ",缓存同步刷新");
return fresh;
}
/**
* 清除单个缓存 ------ @CacheEvict 演示。
* 本章业务上没有"删咖啡"功能,用"重置某款缓存"来演示语义即可。
*/
@CacheEvict(value = "menu", key = "#id")
public void evictOne(Long id) {
System.out.println(">>> 【清缓存】menu[" + id + "] 已移除,下次查询将回库");
}
/**
* 清空整块 menuList 缓存 ------ allEntries = true。
* 典型场景:批量导入/后台全量更新后,逐条失效太麻烦,直接清空整柜。
*/
@CacheEvict(value = "menuList", allEntries = true)
public void evictAllMenuList() {
System.out.println(">>> 【清缓存】menuList 全部条目已移除");
}
/** 下单扣库存(对照实验用:证明高频写数据不适合 @Cacheable) */
public boolean deductStock(Long id, int qty) {
return coffeeMapper.deductStock(id, qty) > 0;
}
}
src/main/java/com/lihaozhe/chapter13/CacheConfig.java
java
package com.lihaozhe.chapter13;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
/**
* 第 13 章:开启缓存能力的总开关。
*
* <p>【@EnableCaching 是什么】
* 它触发 Spring 导入缓存基础设施:BeanPostProcessor 会扫描所有 Bean,
* 给带 @Cacheable/@CachePut/@CacheEvict 的类生成 AOP 代理并挂上
* CacheInterceptor。没有这个开关,三个注解就是三个"装饰性注释",毫无作用。</p>
*
* <p>【缓存管理器从哪来】
* 类路径上有 spring-boot-starter-cache 且无其他实现时,Boot 自动配置
* ConcurrentMapCacheManager------一个 ConcurrentHashMap 当缓存柜,
* 数据存内存、应用重启即清空、没有过期时间(TTL)。
* 生产环境换 RedisCacheManager:加 Redis 依赖 + 配置连接即可,注解代码一行不改。</p>
*/
@Configuration
@EnableCaching
public class CacheConfig {
}
src/main/java/com/lihaozhe/chapter13/MenuController.java
java
package com.lihaozhe.chapter13;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 第 13 章:验证缓存的 Controller ------ 每个 GET 都能当缓存实验做。
*/
@RestController
public class MenuController {
private final MenuService menuService;
public MenuController(MenuService menuService) {
this.menuService = menuService;
}
/** 连续请求两次,观察控制台:第一次打印【查库】,第二次没有 = 命中缓存 */
@GetMapping("/api/menu/{id}")
public Coffee detail(@PathVariable Long id) {
Coffee coffee = menuService.getById(id);
if (coffee == null) {
throw new IllegalArgumentException("咖啡不存在: id=" + id);
}
return coffee;
}
@GetMapping("/api/menu")
public Object list() {
return menuService.getAll();
}
/** 改价格:写库 + @CachePut 刷缓存;随后再查 /api/menu/{id} 应立即看到新价 */
@PostMapping("/api/menu/{id}/price")
public Object changePrice(@PathVariable Long id, @RequestParam BigDecimal price) {
menuService.updatePrice(id, price);
Map<String, Object> result = new LinkedHashMap<>();
result.put("menuId", id);
result.put("newPrice", price);
result.put("hint", "价格已更新并同步缓存,再次 GET /api/menu/" + id + " 验证");
return result;
}
/** 手动清掉某款缓存(演示 @CacheEvict 单条) */
@PostMapping("/api/menu/{id}/cache-evict")
public Object evict(@PathVariable Long id) {
menuService.evictOne(id);
return Map.of("evicted", id, "hint", "缓存已清除,下次查询将重新回库");
}
/** 清空 menuList 整柜(演示 allEntries=true) */
@PostMapping("/api/menu/cache-evict-all")
public Object evictAll() {
menuService.evictAllMenuList();
return Map.of("evicted", "menuList*", "hint", "列表缓存已全部清除");
}
/**
* 对照实验端点:扣库存。
* 缓存里的 stock 是"快照",扣完库存再查详情可能还是旧值------
* 这就是"为什么库存不能无脑 @Cacheable"的活教材。
*/
@PostMapping("/api/menu/{id}/order")
public Object order(@PathVariable Long id, @RequestParam(defaultValue = "1") int qty) {
boolean ok = menuService.deductStock(id, qty);
if (!ok) {
throw new IllegalArgumentException("库存不足");
}
Coffee cachedView = menuService.getById(id); // 注意:这里读的是缓存!
Map<String, Object> result = new LinkedHashMap<>();
result.put("deducted", qty);
result.put("cacheView", cachedView.getStock()); // 缓存视角的库存(可能是旧的)
result.put("warning", "上面 cacheView 来自缓存快照------高频写字段放进缓存会读到旧值");
return result;
}
}
src/main/java/com/lihaozhe/chapter13/SimpleExceptionHandler.java
java
package com.lihaozhe.chapter13;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 第 13 章:简单异常处理(同 ch11/ch12 模式)。
*/
@RestControllerAdvice
public class SimpleExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Object handleIllegalArgument(IllegalArgumentException e) {
return new java.util.LinkedHashMap<String, Object>() {{
put("code", 400);
put("message", e.getMessage());
put("data", null);
}};
}
}
src/main/java/com/lihaozhe/chapter13/CoffeeApplication13.java
java
package com.lihaozhe.chapter13;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 13 章:缓存章启动类。
*/
@SpringBootApplication
public class CoffeeApplication13 {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CoffeeApplication13.class);
app.setAdditionalProfiles("ch13");
app.run(args);
}
}
运行验证
第 1 步:编译启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter13.CoffeeApplication13
第 2 步:场景一 ------ @Cacheable 首次查库、二次命中
连续请求 3 次:
bash
curl http://localhost:8080/api/menu/1
curl http://localhost:8080/api/menu/1 > /dev/null
curl http://localhost:8080/api/menu/1 > /dev/null
控制台只有一条查库日志(第 2、3 次完全静默 = 命中缓存):
text
>>> 【查库】getById(1) 执行了真实 SQL ------ 这行打印说明缓存未命中
返回体正常:{"name":"拿铁","price":28.00,"stock":98,...}。
第 3 步:场景二 ------ @CachePut 写后同步
bash
curl -X POST "http://localhost:8080/api/menu/1/price?price=30.50"
# {"menuId":1,"newPrice":30.50,...}
curl http://localhost:8080/api/menu/1
再查详情立即返回新价 "price":30.50,且日志新增的是【写库+刷缓存】而非【查库】------说明这次读来自被 @CachePut 覆盖过的缓存:
text
>>> 【写库+刷缓存】价格已更新为 30.50,缓存同步刷新
第 4 步:场景三 ------ @CacheEvict 单条清除
bash
curl -X POST http://localhost:8080/api/menu/1/cache-evict
# {"evicted":1,"hint":"缓存已清除,下次查询将重新回库"}
curl http://localhost:8080/api/menu/1 > /dev/null
日志按顺序出现:
text
>>> 【清缓存】menu[1] 已移除,下次查询将回库
>>> 【查库】getById(1) 执行了真实 SQL ------ 这行打印说明缓存未命中
清除后第一次查询重新回库------行为完全符合预期。
第 5 步:场景四 ------ 独立缓存空间 + allEntries 清空
bash
curl http://localhost:8080/api/menu > /dev/null # 第一次:查库
curl http://localhost:8080/api/menu > /dev/null # 第二次:命中
curl -X POST http://localhost:8080/api/menu/cache-evict-all # 清空整柜
curl http://localhost:8080/api/menu > /dev/null # 第三次:又查库
日志恰好三条 getAll 相关:
text
>>> 【查库】getAll() 执行了真实 SQL
>>> 【清缓存】menuList 全部条目已移除
>>> 【查库】getAll() 执行了真实 SQL
第 6 步:场景五 ------ 对照实验:库存为什么不能缓存
先把价格还原为 28,然后下单扣 2 杯:
bash
curl -X POST "http://localhost:8080/api/menu/1/price?price=28" > /dev/null
curl -X POST "http://localhost:8080/api/menu/1/order?qty=2"
返回体揭示了缓存快照问题------数据库里库存已是 96,缓存视角还是 98:
json
{"deducted":2,"cacheView":98,"warning":"上面 cacheView 来自缓存快照------高频写字段放进缓存会读到旧值"}
(验证完通过 SQL 把 id=1 还原为 stock=98、price=28.00。)
常见坑
| 现象 | 原因与解决 |
|---|---|
| 加了 @Cacheable 但每次都查库 | 忘了 @EnableCaching;或同类自调用绕过了代理(见第 10 章) |
| 改了数据但接口返回旧值 | 写方法没配 @CachePut/@CacheEvict,缓存成了脏数据仓库 |
| @CachePut 后缓存里存了错误对象 | key 写成 #id 但方法签名里没有 id 参数;用 #result.xxx 引用返回值 |
| 无参方法缓存互相覆盖 | 默认 key 是 SimpleKey.EMPTY,同空间所有无参方法共用一条记录 |
| 内存占用持续上涨 | ConcurrentMapCacheManager 无 TTL 无上限;换 Caffeine/Redis 并设过期 |
| 缓存对象被外部修改 | 存引用不是拷贝;缓存对象当不可变对待 |
自测题
- @Cacheable 和 @CachePut 最本质的区别是什么?各自适合什么场景?
- 为什么说"没有 @EnableCaching,三个注解就是装饰性注释"?它的底层机制是什么?
key = "#result.id"里的 #result 是什么?哪个注解里最常用?- ConcurrentMapCacheManager 有哪三个生产环境不能容忍的限制?
- 缓存穿透/击穿/雪崩分别是什么?一句话说出三者的核心区别?
- 本章实验中扣完 2 杯库存后 cacheView 还是 98,这说明什么类型的数据不适合缓存?判断标准是哪三条?
下一章预告
下单后没人支付,订单永远停在"待支付"------谁来管超时?
下一章学习定时任务与异步:@Scheduled cron 表达式让订单超时自动取消,@Async 线程池让发通知这类慢操作不阻塞主流程,顺便认识事件监听这种更优雅的解耦方式。
第 14 章:定时任务与异步 ------ 订单超时自动取消
本章目标
- 掌握 @EnableScheduling + @Scheduled 三种触发方式(fixedRate/fixedDelay/cron)
- 实战"订单超时自动取消":定时扫表 + 乐观并发更新
- 掌握 @EnableAsync + @Async + 自定义线程池
- 理解两个注解的 AOP 本质与自调用坑(第 10 章知识复用)
- 了解分布式部署下定时任务的问题(多实例重复执行)
用户下单后不支付,订单永远停在 CREATED、库存一直被占着------谁来管超时?本章让应用自己长出"闹钟"和"分身":@Scheduled 定时巡检,@Async 异步发通知。
知识点讲解
@Scheduled 三种触发方式
| 方式 | 写法 | 语义 |
|---|---|---|
| fixedRate | @Scheduled(fixedRate = 10000) |
上次开始时间起算,每 10 秒一次 |
| fixedDelay | @Scheduled(fixedDelay = 10000) |
上次结束时间起算,间隔 10 秒(不重叠) |
| cron | @Scheduled(cron = "0/10 * * * * *") |
六位表达式,最灵活 |
cron 六位从左到右:秒 分 时 日 月 周
0/10 * * * * * 从第 0 秒开始每 10 秒
0 0 2 * * * 每天凌晨 2 点整
0 30 9 ? * MON 每周一早上 9:30(? 表示不指定日)
0 0 0 1 * * 每月 1 号零点
@Scheduled 的线程模型(重要)
默认所有 @Scheduled 方法共享一个调度线程------一个任务跑得慢,其他任务全被堵住。解法两种:
java
// Java 配置方式(本章用法)
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2); // 两个任务并行不阻塞
scheduler.setThreadNamePrefix("sched-");
scheduler.initialize();
return scheduler;
}
yaml
# 配置文件方式(等价,二选一即可)
spring:
task:
scheduling:
pool:
size: 2
thread-name-prefix: sched-
超时取消的并发安全设计
巡检逻辑有个隐藏竞态:定时任务读订单时是 CREATED,但 UPDATE 前用户刚好付了款。如果无脑 UPDATE 就会"已支付还被取消"。
标准防御:UPDATE 的 WHERE 带上状态条件:
sql
UPDATE coffee_order SET status = 'CANCELLED'
WHERE id = #{id} AND status = 'CREATED'
影响行数 = 1 → 取消成功;= 0 → 刚被支付线程抢先改了状态。谁先 UPDATE 谁赢,天然安全。这就是乐观并发控制 的思想(对比第 08 章 WHERE stock >= #{qty} 防超卖------同一个模式)。
@Async 与线程池
java
@Async("notifyExecutor") // Bean 名对应自定义线程池
public void sendCancelNotice(String orderNo) { ... }
调用方 → AOP 代理 → 任务提交到 notifyExecutor 线程池 → 立即返回;真正的方法体在线程池线程里跑。
三个必知点:
- 必须 @EnableAsync,否则注解被无视、退化为同步执行
- 自调用不生效------同类内 this.sendCancelNotice() 绕过代理(第 10/13 章同款坑)。所以 @Async 方法要放在独立 Bean 里
- 默认池不可用于生产------SimpleAsyncTaskExecutor 每个任务裸 new 线程,无复用无上限。必须自定义 ThreadPoolTaskExecutor(核心 2 / 最大 4 / 队列 100)
分布式部署的坑(认知储备)
定时任务是"每台 JVM 各跑各的"。集群部署时同一时刻 N 个实例都会执行巡检 → 重复取消/重复通知。生产解法:
- ShedLock:加表/Redis 锁保证同一时刻只有一个实例跑
- Redis 分布式锁:SETNX 抢锁再干活(后续 Redis 章节实战)
教学单实例暂不引入,但要能说出这个问题。
完整代码(最终版)
本章共 7 个新文件 + application.yaml 新增 ch14 段。
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 14 章】定时任务与异步章:数据库配置同前。
# app.order.timeout-minutes 是超时阈值(演示设 0 = 下单即超时,方便观察);
# 定时/异步线程池在 ScheduleConfig 里用 Java 配置(也可用
# spring.task.scheduling.* / spring.task.execution.* 配置文件方式)。
# ============================================================
---
spring:
config:
activate:
on-profile: ch14
autoconfigure:
exclude: []
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_coffee?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
idle-timeout: 600000
max-lifetime: 1800000
sql:
init:
mode: never
# 自定义业务配置:订单超时阈值(分钟)。
# 演示设 0------刚下的单立刻"过期",不用真等一分钟。
app:
order:
timeout-minutes: 0
mybatis:
configuration:
map-underscore-to-camel-case: true
src/main/java/com/lihaozhe/chapter14/CoffeeOrder.java
java
package com.lihaozhe.chapter14;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 第 14 章:订单实体。
*/
public class CoffeeOrder {
/** 订单 id */
private Long id;
/** 订单号(对外展示) */
private String orderNo;
/** 会员 id(未登录为 null) */
private Long memberId;
/** 订单总额 */
private BigDecimal totalAmount;
/** 状态:CREATED 已创建 / PAID 已支付 / CANCELLED 已取消 */
private String status;
/** 下单时间(超时判定的依据) */
private LocalDateTime createdAt;
/** 支付时间 */
private LocalDateTime paidAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getOrderNo() {
return orderNo;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
public Long getMemberId() {
return memberId;
}
public void setMemberId(Long memberId) {
this.memberId = memberId;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getPaidAt() {
return paidAt;
}
public void setPaidAt(LocalDateTime paidAt) {
this.paidAt = paidAt;
}
}
src/main/java/com/lihaozhe/chapter14/OrderMapper.java
java
package com.lihaozhe.chapter14;
import java.util.List;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 第 14 章:订单 Mapper ------ 定时任务的"作业面"。
*/
@Mapper
public interface OrderMapper {
@Insert("""
INSERT INTO coffee_order (order_no, member_id, total_amount, status)
VALUES (#{orderNo}, #{memberId}, #{totalAmount}, 'CREATED')
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(CoffeeOrder order);
@Select("SELECT id, order_no AS orderNo, member_id AS memberId, total_amount AS totalAmount, "
+ "status, created_at AS createdAt, paid_at AS paidAt FROM coffee_order WHERE id = #{id}")
CoffeeOrder findById(@Param("id") Long id);
/** 查所有待支付订单(演示用;真实项目要 LIMIT 分页扫) */
@Select("SELECT id, order_no AS orderNo, member_id AS memberId, total_amount AS totalAmount, "
+ "status, created_at AS createdAt, paid_at AS paidAt "
+ "FROM coffee_order WHERE status = 'CREATED' ORDER BY id")
List<CoffeeOrder> findAllCreated();
/**
* 超时取消的核心 SQL:只有仍处于 CREATED 状态的订单才会被改成 CANCELLED。
* WHERE 带 status 条件是"乐观"写法------支付与取消并发时,谁先 UPDATE 谁赢,
* 后到的语句影响行数为 0,天然避免"已支付还被取消"的事故。
*/
@Update("""
UPDATE coffee_order SET status = 'CANCELLED'
WHERE id = #{id} AND status = 'CREATED'
""")
int cancelIfCreated(@Param("id") Long id);
}
【翻车案例】第一版 OrderMapper 忘了 @Mapper 注解,启动直接报:
Parameter 0 of constructor in OrderController required a bean of type 'OrderMapper' that could not be found------接口没有代理实现就不会注册为 Bean。每个 Mapper 接口都必须有 @Mapper(或启动类 @MapperScan)。
src/main/java/com/lihaozhe/chapter14/ScheduleConfig.java
java
package com.lihaozhe.chapter14;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
/**
* 第 14 章:定时 + 异步的总开关与线程池配置。
*
* <p>【两个开关】
* @EnableScheduling ------ 让 @Scheduled 生效(缺了它定时任务永远不跑)
* @EnableAsync ------ 让 @Async 生效(缺了它异步方法退化为同步)</p>
*
* <p>【为什么自己配线程池】
* 定时任务默认池只有 1 个线程:一个任务跑得慢,其他任务全被堵住;
* @Async 默认用 SimpleAsyncTaskExecutor:每个任务裸 new 线程,无复用无上限。
* 生产系统这两个都必须显式配置------这也是面试常问点。</p>
*/
@Configuration
@EnableScheduling
@EnableAsync
public class ScheduleConfig {
/**
* 定时任务专用调度器。
* yaml 里 spring.task.scheduling.* 也能配,这里用 Java 配置演示另一种方式,
* 并把池开到 2(巡检任务 + 演示任务互不阻塞)。
*/
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("sched-");
scheduler.initialize();
return scheduler;
}
/**
* 异步通知专用线程池(核心 2 / 最大 4 / 队列 100)。
* Bean 名 notifyExecutor 与 @Async("notifyExecutor") 对应;
* 参数语义见第 02 章 ThreadPoolExecutor 理论侧栏。
*/
@Bean
public Executor notifyExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("notify-");
executor.initialize();
return executor;
}
}
src/main/java/com/lihaozhe/chapter14/NotifyService.java
java
package com.lihaozhe.chapter14;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 第 14 章:异步通知 Service ------ @Async 的正确打开方式。
*
* <p>【@Async 的原理】与 @Scheduled/@Cacheable 同源:AOP 代理。
* 调用方 → 代理 → 提交任务到异步线程池 → 立即返回(不等执行完),
* 真正的方法体在线程池的某个线程里跑。</p>
*
* <p>【两个必踩的坑】
* ① 必须 @EnableAsync 才生效,否则还是同步执行(注解被无视);
* ② 自调用不生效------OrderTimeoutService 里如果直接 this.sendCancelNotice()
* 就绕过了代理,通知会"同步"跑在定时线程里。所以本章把 @Async 方法
* 放在独立的 NotifyService 里,跨 Bean 调用必然经过代理。</p>
*
* <p>【线程池从哪来】默认用 SimpleAsyncTaskExecutor------每来一个任务
* new 一个裸线程,不复用、无上限,生产必须自定义 ThreadPoolTaskExecutor
* (本章在 AsyncConfig 里显式配置了一个)。</p>
*/
@Service
public class NotifyService {
private static final Logger log = LoggerFactory.getLogger(NotifyService.class);
/**
* 发送取消通知(模拟调用外部短信网关,耗时 1 秒)。
*
* @Async 无返回值:调用方发完就忘(fire-and-forget)。
* 线程名会显示我们自定义池的前缀 "notify-",这是验证异步生效的证据。
*/
@Async("notifyExecutor")
public void sendCancelNotice(String orderNo) {
String thread = Thread.currentThread().getName();
log.info("[异步通知] 开始发送 ------ 订单 {} 已超时取消(线程: {})", orderNo, thread);
try {
Thread.sleep(1000); // 模拟调外部网关的网络耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
log.info("[异步通知] 发送完成 ------ 订单 {}", orderNo);
}
}
src/main/java/com/lihaozhe/chapter14/OrderTimeoutService.java
java
package com.lihaozhe.chapter14;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 第 14 章:订单超时自动取消 ------ @Scheduled 的实战场景。
*
* <p>【业务背景】用户下单后不支付,订单永远停在 CREATED,
* 库存也被占着。电商系统的标准解法:定时任务扫描超时未支付订单并取消。
* (大厂高并发场景用"Redis 过期事件/延迟队列",定时扫表是中小规模
* 最朴素可靠的方案------先掌握它。)</p>
*
* <p>【@Scheduled 三种常用写法】
* fixedRate = 上次【开始】时间起算,每 N 毫秒一次(不管上次多久跑完)
* fixedDelay = 上次【结束】时间起算,间隔 N 毫秒(保证不重叠)
* cron = 六位表达式"秒 分 时 日 月 周",最灵活(本章用这个)</p>
*
* <p>【并发安全要点】单机默认所有 @Scheduled 共享【一个】调度线程池
* (大小 1),长任务会拖住其他任务;本章在 yaml 里配了独立线程池。
* 分布式多实例部署时每个实例都会跑一遍定时任务 → 需要分布式锁
* (如 ShedLock/Redis 锁),教学单实例暂不引入。</p>
*/
@Service
public class OrderTimeoutService {
private final OrderMapper orderMapper;
/** 超时阈值(分钟),yaml 可覆盖;默认 1 分钟方便演示 */
@Value("${app.order.timeout-minutes:1}")
private int timeoutMinutes;
/** @Async 目标 Bean 用 setter 注入(避免构造器注入顺序问题) */
private NotifyService notifyService;
public OrderTimeoutService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
@Autowired
public void setNotifyService(NotifyService notifyService) {
this.notifyService = notifyService;
}
/**
* 每 10 秒执行一次的超时巡检。
*
* <p>cron 六位从左到右:秒 分 时 日 月 周。
* "0/10 * * * * *" = 从第 0 秒开始每 10 秒。
* 注意日/月位置的 * 与 ? 都可(Spring 支持 ? 表示"不指定")。</p>
*/
@Scheduled(cron = "0/10 * * * * *")
@Transactional
public void cancelTimeoutOrders() {
LocalDateTime deadline = LocalDateTime.now().minusMinutes(timeoutMinutes);
List<CoffeeOrder> created = orderMapper.findAllCreated();
int cancelled = 0;
for (CoffeeOrder order : created) {
// 只处理"下单时间早于截止线"的订单
if (order.getCreatedAt().isBefore(deadline)) {
// cancelIfCreated 返回影响行数:
// 1 = 取消成功;0 = 刚好被支付线程抢先改了状态(乐观并发)
if (orderMapper.cancelIfCreated(order.getId()) > 0) {
cancelled++;
System.out.println(">>> [定时任务] 订单 " + order.getOrderNo()
+ " 超时未支付(下单于 " + order.getCreatedAt() + "),已自动取消");
notifyUserAsync(order); // 触发异步通知(见下)
}
}
}
if (cancelled == 0) {
System.out.println(">>> [定时任务] 巡检完成:本轮无超时订单");
}
}
/**
* 发通知是典型的"慢操作"(要调短信/推送网关),
* 不能让定时线程干等 ------ 交给 @Async 线程池异步执行。
* 这里只是触发入口;真正的 @Async 方法在 NotifyService 里(跨 Bean 调用才走代理!)。
*/
private void notifyUserAsync(CoffeeOrder order) {
notifyService.sendCancelNotice(order.getOrderNo());
}
}
src/main/java/com/lihaozhe/chapter14/OrderController.java
java
package com.lihaozhe.chapter14;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
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.RestController;
/**
* 第 14 章:订单接口 ------ 造测试数据 + 观察定时任务效果。
*/
@RestController
public class OrderController {
private final OrderMapper orderMapper;
public OrderController(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
/** 创建一个待支付订单(回查数据库返回完整字段,含数据库生成的 createdAt) */
@PostMapping("/api/order")
public CoffeeOrder create() {
CoffeeOrder order = new CoffeeOrder();
order.setOrderNo("CO" + UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase());
order.setTotalAmount(new java.math.BigDecimal("28.00"));
orderMapper.insert(order);
return orderMapper.findById(order.getId());
}
/** 查看订单当前状态(等 10 秒再查,观察 CREATED → CANCELLED) */
@GetMapping("/api/order/{id}")
public CoffeeOrder detail(@PathVariable Long id) {
return orderMapper.findById(id);
}
/** 手动列出所有待支付订单(巡检前的"靶子清单") */
@GetMapping("/api/orders/created")
public Object createdList() {
return orderMapper.findAllCreated();
}
/** 首页提示(可选访问) */
@GetMapping("/api/schedule-info")
public Object info() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("scheduled", "每 10 秒巡检一次,超时阈值见 app.order.timeout-minutes");
m.put("async", "取消后通知走 notifyExecutor 线程池,线程名 notify-*");
return m;
}
}
src/main/java/com/lihaozhe/chapter14/SimpleExceptionHandler.java
java
package com.lihaozhe.chapter14;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 第 14 章:简单异常处理(同 ch11~ch13 模式)。
*/
@RestControllerAdvice
public class SimpleExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Object handleIllegalArgument(IllegalArgumentException e) {
return new java.util.LinkedHashMap<String, Object>() {{
put("code", 400);
put("message", e.getMessage());
put("data", null);
}};
}
}
src/main/java/com/lihaozhe/chapter14/CoffeeApplication14.java
java
package com.lihaozhe.chapter14;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
/**
* 第 14 章:定时任务与异步章启动类。
*/
@SpringBootApplication
public class CoffeeApplication14 {
public static void main(String[] args) {
new SpringApplicationBuilder(CoffeeApplication14.class)
.profiles("ch14")
.run(args);
}
}
运行验证
第 1 步:编译启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter14.CoffeeApplication14
启动后约 10 秒,控制台开始周期性出现(调度线程 sched-* 在工作):
text
>>> [定时任务] 巡检完成:本轮无超时订单
第 2 步:下单造"靶子"
bash
curl -X POST http://localhost:8080/api/order
返回(timeout-minutes=0,刚下的单立刻符合超时条件):
json
{"createdAt":"2026-08-25T11:55:50","id":6,"memberId":null,"orderNo":"COFE26D6D4E98C","paidAt":null,"status":"CREATED","totalAmount":28.00}
第 3 步:等一轮巡检(最多 10 秒),再查订单
bash
sleep 12 && curl http://localhost:8080/api/order/6
状态已被自动取消:
json
{"createdAt":"2026-08-25T11:55:50","id":6,"orderNo":"COFE26D6D4E98C","status":"CANCELLED","totalAmount":28.00}
第 4 步:看日志验证完整链路(取消 + 异步通知)
控制台按顺序出现两段关键日志:
text
>>> [定时任务] 订单 COFE26D6D4E98C 超时未支付(下单于 2026-08-25T11:55:50),已自动取消
... [ notify-2] c.l.c.NotifyService : [异步通知] 开始发送 ------ 订单 COFE26D6D4E98C 已超时取消(线程: notify-2)
... [ notify-2] c.l.c.NotifyService : [异步通知] 发送完成 ------ 订单 COFE26D6D4E98C
证据链完整:
- 巡检由
sched-1调度线程触发 - 通知在
notify-2线程执行------不是 sched 线程,证明 @Async 生效且走了自定义池 - 通知"开始/完成"间隔约 1 秒,而巡检循环没有等待它------异步不阻塞主流程
第 5 步:观察空轮巡检
没有新订单时每 10 秒一条:
text
>>> [定时任务] 巡检完成:本轮无超时订单
(验证完删除测试订单,恢复数据库基线。)
常见坑
| 现象 | 原因与解决 |
|---|---|
| @Scheduled 方法永远不执行 | 缺 @EnableScheduling;或方法有参数(不允许);或类没被扫描到 |
| @Async 变成了同步执行 | 缺 @EnableAsync;或同类自调用绕过代理(放独立 Bean) |
| 一个定时任务卡住全部任务 | 默认单线程调度池;配 ThreadPoolTaskScheduler 并设 poolSize |
| 应用关闭时任务突然中断 | 未配优雅停机(第 16 章处理);任务里做好幂等 |
| 集群部署重复执行 | 每个 JVM 各跑一份;上 ShedLock 或 Redis 分布式锁 |
| 启动报 OrderMapper 找不到 | Mapper 接口漏了 @Mapper 注解(本章真实踩过) |
自测题
- fixedRate 和 fixedDelay 的区别是什么?各自适合什么任务?
- cron 表达式
0 0 2 * * *代表什么?六位分别是什么含义? - 超时取消 SQL 里
AND status = 'CREATED'防御的是什么竞态?这和第 8 章防超卖的哪个写法是同一思想? - 为什么 NotifyService 要单独建一个 Bean?放 OrderTimeoutService 里会发生什么?
- @Async 默认线程池有什么问题?自定义 ThreadPoolTaskExecutor 时核心/最大/队列分别怎么设?
- 集群部署时定时任务会有什么问题?说出两种解法。
下一章预告
代码写完了,怎么证明它是对的?
下一章进入测试篇:JUnit 5 断言、@SpringBootTest 整合测试、MockMvc 不起 Tomcat 测 Controller、@WebMvcTest + @MockitoBean 只测 Web 层、以及事务自动回滚的数据库测试------让每次改代码都有底气。