SpringBoot4 云端咖啡站 阶段四:安全、文件与性能

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 三大优点:

  1. :故意设计成约 100ms 一次计算,暴力破解成本极高
  2. 自带随机盐:同一密码每次注册生成的哈希都不同,防彩虹表
  3. 自描述 :哈希串内含盐和成本因子,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 等慢哈希

自测题

  1. 认证和授权分别回答什么问题?
  2. Session 方案在集群部署时的两大痛点是什么?JWT 怎么解决?
  3. JWT 的三段各是什么?哪一段保证防篡改?payload 加密吗?
  4. 为什么同一密码每次注册生成的 BCrypt 哈希都不一样?matches 怎么还能比对?
  5. Interceptor 相比 Filter 做认证的两个优势是什么?preHandle 返回 false 意味着什么?
  6. 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() 读内容流

安全三板斧(缺一不可)

  1. 类型白名单:只放行 jpg/jpeg/png/webp。防上传 .jsp/.exe 等可执行文件被服务器解析或下载执行
  2. 大小限制:yaml 里 max-file-size: 2MB。防磁盘被恶意塞爆(DoS)
  3. 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

自测题

  1. 文件上传安全三板斧是哪三条?各自防御什么攻击?
  2. 为什么绝对不能用 getOriginalFilename() 的值拼存储路径?
  3. 数据库里 image 列存的是什么?为什么不像 BLOB 那样存内容?
  4. MaxUploadSizeExceededException 为什么需要单独的 handler?它的 HTTP 状态码应该用什么?
  5. addResourceHandlers 把什么映射到了什么?toUri() 输出的形式长什么样?
  6. 本地磁盘存上传文件在集群部署时有什么问题?业界的解法是什么?

下一章预告

每次查询菜单都要跑一趟数据库------数据明明没变,却反复付连接开销。下一章学习 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 并设过期
缓存对象被外部修改 存引用不是拷贝;缓存对象当不可变对待

自测题

  1. @Cacheable 和 @CachePut 最本质的区别是什么?各自适合什么场景?
  2. 为什么说"没有 @EnableCaching,三个注解就是装饰性注释"?它的底层机制是什么?
  3. key = "#result.id" 里的 #result 是什么?哪个注解里最常用?
  4. ConcurrentMapCacheManager 有哪三个生产环境不能容忍的限制?
  5. 缓存穿透/击穿/雪崩分别是什么?一句话说出三者的核心区别?
  6. 本章实验中扣完 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 线程池 → 立即返回;真正的方法体在线程池线程里跑。

三个必知点:

  1. 必须 @EnableAsync,否则注解被无视、退化为同步执行
  2. 自调用不生效------同类内 this.sendCancelNotice() 绕过代理(第 10/13 章同款坑)。所以 @Async 方法要放在独立 Bean 里
  3. 默认池不可用于生产------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 注解(本章真实踩过)

自测题

  1. fixedRate 和 fixedDelay 的区别是什么?各自适合什么任务?
  2. cron 表达式 0 0 2 * * * 代表什么?六位分别是什么含义?
  3. 超时取消 SQL 里 AND status = 'CREATED' 防御的是什么竞态?这和第 8 章防超卖的哪个写法是同一思想?
  4. 为什么 NotifyService 要单独建一个 Bean?放 OrderTimeoutService 里会发生什么?
  5. @Async 默认线程池有什么问题?自定义 ThreadPoolTaskExecutor 时核心/最大/队列分别怎么设?
  6. 集群部署时定时任务会有什么问题?说出两种解法。

下一章预告

代码写完了,怎么证明它是对的?

下一章进入测试篇:JUnit 5 断言、@SpringBootTest 整合测试、MockMvc 不起 Tomcat 测 Controller、@WebMvcTest + @MockitoBean 只测 Web 层、以及事务自动回滚的数据库测试------让每次改代码都有底气。

相关推荐
南城以南溫暖如初14727 分钟前
从零搭建24小时自助健身系统:技术选型与核心模块实战
java·spring boot·redis·mysql·vue·mybatis
Linux运维技术栈1 小时前
筑牢业务信息安全防线:雷池WAF+后端日志溯源的全链路防护体系落地实践
linux·安全·雷池
MartinYeung51 小时前
[论文学习]医学AI安全风险分类:S1-S4分级框架深度分析
人工智能·学习·安全
小小杨树1 小时前
【C++】学习:双缓冲视频流水线深解析
c++·性能优化·音视频开发
P1Browser2 小时前
指纹浏览器安全吗?从浏览器环境隔离、数据存储到安全风险解析
网络·tcp/ip·安全·网络安全·github·php
李昊哲小课2 小时前
SpringBoot4 云端咖啡站 阶段五:交付与进阶
人工智能·spring boot·大模型·log4j·智能体
KKKlucifer2 小时前
运维域与IT域融合——云网安全一体化运营体系建设
大数据·运维·安全
全栈小53 小时前
【C#】.net core,静态方法线程安全解析,面试时经常被问的线程安全就藏在里面
安全·c#·.netcore
️学习的小王3 小时前
AI编码工具的安全陷阱:Copilot/Codex漏洞风险与防御实践
人工智能·安全·copilot