前端工程师的 Java 后端 30 天快速入门· Week 2(3)

133 阅读52分钟

前端工程师的 Java 后端 30 天快速入门 · Week 2:Spring Boot 与数据访问(Day 8-14)

IoC/DI、REST API、校验与全局异常、配置管理、MySQL + JPA/MyBatis、索引与数据库迁移。学完能交付一套带数据库的 CRUD 服务。 每天建议投入 2–3 小时(周末可加练)。

项目说明
技术栈Java 21 + Spring Boot 3 + MySQL 8 + Redis 7 + RabbitMQ + Docker
学习法每天「目标 → 要点 → 代码 → 动手 → 自测 → 避坑」闭环,必须写代码

Day 8 · Spring Boot 起步:10 分钟跑起一个后端服务

所属阶段:Week 2 · Spring Boot 与数据访问 | 建议时长:2-3h | 进度:第 8 / 30 天

今日导语:今天进入框架世界。目标三件事:用官方脚手架生成一个能跑的服务、说清它为什么“不用配置就能用”、写出第一个返回 JSON 的接口。Spring Boot 的魔法集中在“自动配置”上,理解它之后,所有 starter 你都能举一反三。

前端视角:直觉映射:Spring Boot ≈ 后端的 Next.js / NestJS——约定大于配置、开箱即用、自带服务器。@SpringBootApplication ≈ 应用入口 + 依赖扫描 + 自动装配三合一。application.yml ≈ .env + vite.config 的合体。

🎯 今日目标

  • 用 start.spring.io 初始化项目并理解标准目录结构
  • 理解自动配置(@EnableAutoConfiguration + 条件注解)与内嵌容器原理
  • 写出第一个返回 JSON 的 HTTP 接口,并打成可执行 jar 运行

📌 脚手架与起步依赖

  • start.spring.io 选择:Maven + Java 21 + Spring Boot 3.x,依赖勾选 Spring Web、Validation、Lombok、Spring Data JPA、MySQL Driver、Spring Boot DevTools。
  • starter 的本质:一组预置依赖坐标 + 一个自动配置类。spring-boot-starter-web = Spring MVC + Jackson + 内嵌 Tomcat + 日志,一个依赖搞定一个能力域。
    • 命名约定:官方 starter 叫 spring-boot-starter-xxx,第三方叫 xxx-spring-boot-starter(如 mybatis-spring-boot-starter)。
    • 一个 starter 通常包含:依赖聚合 pom + 自动配置类 + @ConfigurationProperties 配置类 + spring.factories/AutoConfiguration.imports 声明。
    • 自定义 starter 是团队沉淀公共能力的标准方式(如统一日志 starter、多租户 starter)。
  • spring-boot-starter-parent 统一管理依赖版本与插件配置——有了它,写依赖时不用写 。
  • 目录:启动类放根包(com.demo.api),其他类放子包(controller / service / repo),因为组件扫描默认只扫启动类所在包及其子包。
  • resources 下:application.yml(主配置)、static/(静态资源)、db/migration/(Flyway 脚本)。
    • application.yml 支持多文档块(--- 分隔),可把 profile 配置写在同一文件里;但更推荐拆成 application-{profile}.yml 便于管理。
    • 敏感配置不要放 resources(会打进 jar),用环境变量或外部挂载。

📌 自动配置原理

  • @SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan。
  • 流程:启动时加载 AutoConfiguration.imports 里列出的一批配置类,每个用条件注解判断是否生效:@ConditionalOnClass(classpath 有某类)、@ConditionalOnMissingBean(用户没自定义)、@ConditionalOnProperty(配置开关)。
  • 结论:“你引入了什么依赖 + 你没自定义什么 Bean” 决定 Spring 给你配好什么。比如引入 starter-data-redis 且没自定义 RedisTemplate,就自动配一个。
    • 想看某个自动配置为什么没生效:--debug 后搜索类名,Negative matches 下会列出每个未匹配的条件及原因。
    • 想覆盖默认行为:自己定义一个同类型的 @Bean(@ConditionalOnMissingBean 会退让),或改对应的配置属性。
  • 排查工具:启动时加 --debug 看 ConditionEvaluationReport;或看 /actuator/conditions(Day 24);排除某个自动配置用 @SpringBootApplication(exclude = ...)。
    • /actuator/beans 列出所有 Bean 及其来源;/actuator/conditions 看自动配置匹配情况(需要暴露端点)。
    • /actuator/env 看所有配置源与优先级,排查“配置没生效”最有用。
  • 配置绑定:@ConfigurationProperties 把 yml 前缀绑成对象(如 ServerProperties 绑定 server.*),所以改 yml 就能改变行为。

📌 内嵌容器与运行方式

  • Spring Boot 默认内嵌 Tomcat,打成 fat jar(BOOT-INF/lib 塞满依赖),java -jar app.jar 即可运行——不需要外部 Web 服务器,这正是容器化部署便利的根源。
    • Tomcat 的关键参数:server.tomcat.max-threads(默认 200)、accept-count、max-connections —— 高并发场景需要按压测调优。
    • 线程池打满的表现:请求排队、超时,但 CPU 可能不高;监控要看 Tomcat 的当前线程数与队列。
  • 想换 Undertow / Jetty:排除 spring-boot-starter-tomcat,引入对应 starter。响应式场景用 WebFlux + Netty。
  • 开发期效率:spring-boot-devtools 提供热重启;IDEA 里也可开启 “Update classes and resources”。
  • 端口:server.port=8080;随机端口 server.port=0(测试常用);上下文路径 server.servlet.context-path=/api。
    • 随机端口常用于测试(@SpringBootTest(webEnvironment=RANDOM_PORT)),避免并行测试互相抢端口。
    • context-path 与反向代理路径要对齐,否则前端静态资源的相对路径会 404。
  • 启动参数:java -jar app.jar --spring.profiles.active=prod --server.port=8081,命令行参数优先级最高。

📌 启动流程与启动问题排查

  • Spring Boot 启动做了什么
    • ① 创建 SpringApplication → ② 推断应用类型(Servlet/Reactive)→ ③ 加载 ApplicationContextInitializer 与监听器 → ④ 准备 Environment(读取配置源)→ ⑤ 创建并刷新容器(扫描 Bean、执行自动配置、实例化单例)→ ⑥ 启动内嵌 Web 服务器 → ⑦ 发布 ApplicationReadyEvent。
    • Runner:实现 ApplicationRunner / CommandLineRunner 可在启动完成后执行初始化(数据预热、缓存加载)。
    • 启动失败会输出 “APPLICATION FAILED TO START” + Action 建议,这是最好的排查入口。
  • 最常见的 5 类启动失败
    • 端口被占用:Web server failed to start. Port 8080 was already in use → 换端口或 lsof -i:8080 杀进程。
    • Bean 找不到:NoSuchBeanDefinition / Consider defining a bean → 检查扫描包路径、是否漏了 @Service/@Repository。
    • 配置缺失:Could not resolve placeholder 'xxx' → 环境变量或 yml 没配,给默认值 ${xxx:default}。
    • 数据库连接失败:Communications link failure / Access denied → 检查地址、账号、SSL、时区参数。
    • 循环依赖:BeanCurrentlyInCreationException → 见 Day 9。
  • 启动速度优化
    • 懒初始化:spring.main.lazy-initialization=true(启动快,但首个请求变慢,且掩盖 Bean 问题,慎用于生产)。
    • 减少组件扫描范围、去掉不必要的 starter 依赖。
    • 极端场景:Spring AOT + GraalVM 原生镜像(毫秒级启动,适合 Serverless)。
  • 配置加载顺序与位置
    • 默认读取位置(优先级从高到低):./config/ → ./ → classpath:/config/ → classpath:/。
    • 可以用 --spring.config.location 或 SPRING_CONFIG_ADDITIONAL_LOCATION 挂载外部配置(容器部署常用)。
  • Banner 与启动日志:可自定义 banner.txt;生产建议保留启动耗时与版本信息(配合 /actuator/info)。

💻 启动类与第一个接口

package com.demo.api;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(ApiApplication.class, args);
    }
}

// Controller:处理 HTTP 请求
package com.demo.api.controller;

@RestController                      // = @Controller + @ResponseBody(返回 JSON)
@RequestMapping("/api")
public class HelloController {

    @GetMapping("/hello")
    public Map<String, Object> hello(@RequestParam(defaultValue = "world") String name) {
        return Map.of("msg", "Hello " + name, "ts", Instant.now().toString());
    }

    // 返回对象会被 Jackson 自动序列化为 JSON
    @GetMapping("/health")
    public R<Map<String, Object>> health() {
        return R.ok(Map.of("status", "UP", "time", LocalDateTime.now().toString()));
    }
}

💻 application.yml 常用配置

server:
  port: 8080
  shutdown: graceful              # 优雅停机:等请求处理完再关闭
spring:
  application:
    name: demo-api
  jackson:
    default-property-inclusion: non_null     # null 字段不返回
    time-zone: Asia/Shanghai
  lifecycle:
    timeout-per-shutdown-phase: 30s
logging:
  level:
    root: INFO
    com.demo: DEBUG
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"

💻 构建与运行

mvn spring-boot:run                      # 开发期直接跑
mvn clean package -DskipTests           # 打包
java -jar target/demo-api-0.0.1.jar     # 运行 fat jar

# 覆盖配置(命令行参数优先级最高)
java -jar app.jar --spring.profiles.active=prod --server.port=8081

curl localhost:8080/api/hello?name=Tom
curl -s localhost:8080/api/health | jq

🛠 动手任务

  • 初始化项目(Web + Validation + Lombok + DevTools),把端口改成 8081,新增 /api/health 返回 JSON,用 curl 和浏览器各调一次。
  • mvn package 打出 jar 后 java -jar 启动,体会“一个文件就是一个服务”;再用 --server.port=8082 覆盖配置启动一次。
  • 加 --debug 启动一次,在日志里找到 “Positive matches / Negative matches”,看看哪些自动配置生效了、哪些因为什么条件没生效。

✅ 自测题与参考答案(3 题)

Q1. 自动配置是怎么“猜”出你想要什么的?

参考答案(点击展开)

三步:① classpath 探测(@ConditionalOnClass)——发现你有 JDBC 驱动和 spring-jdbc,就准备配数据源;② 用户优先(@ConditionalOnMissingBean)——如果你自己定义了 RedisTemplate,自动配置就退让,保证“约定不压倒配置”;③ 属性开关(@ConditionalOnProperty)——如 management.endpoint.health.enabled=false 可整体关闭。启动时 Spring Boot 从 AutoConfiguration.imports 读取上百个自动配置类,逐个用条件注解过滤,最终决定容器里有哪些 Bean。排查“为什么没生效”用 --debug:ConditionEvaluationReport 的 Negative matches 会明确告诉你卡在哪个条件上(比如缺少某个类、或你已自定义了同名 Bean)。

Q2. 启动类放在 com.demo 而 Controller 放在 com.other,为什么扫不到?

参考答案(点击展开)

@SpringBootApplication 里的 @ComponentScan 默认扫描启动类所在包及其所有子包。Controller 在 com.other 不在扫描范围内,就不会被注册成 Bean,访问时返回 404,且启动日志里没有 “Mapped ...” 的映射信息。解决:① 把启动类放到最外层根包(推荐,如 com.demo,业务放 com.demo.xxx);② 显式指定 @ComponentScan("com");③ 用 @Import 引入。同理,第三方 jar 里的组件也不会被自动扫描,需要靠 starter 的自动配置类或 @EnableXxx 注解引入。

Q3. fat jar 和普通 jar 有什么区别?为什么它能直接 java -jar 运行?

参考答案(点击展开)

普通 jar 只有你自己的 class;fat jar(executable jar)结构是 BOOT-INF/classes(你的代码)+ BOOT-INF/lib(全部依赖 jar)+ org/springframework/boot/loader(启动器),MANIFEST.MF 里指定 Main-Class: JarLauncher、Start-Class: 你的启动类。JarLauncher 用自定义 ClassLoader 加载 BOOT-INF 下的类与嵌套 jar(Java 原生不支持 jar in jar),然后反射调用你的 main。所以无需外部容器、无需配置 classpath,一条 java -jar 就能跑。注意:fat jar 不能被当作普通依赖被别人 import(要用就另外打一个 plain jar,spring-boot-maven-plugin 可配置 classifier)。

⚠️ 前端容易踩的坑

  • 启动类位置太浅或太深导致组件扫描不到,报错“找不到 Bean”或接口 404。
  • 在 Controller 里 new Service(),绕过 Spring 容器,导致 Service 里的 @Transactional、@Autowired 全部失效(因为对象不是容器创建的)。
  • 改了 yml 不重启就以为生效(devtools 只在开发期有效,生产要靠配置中心或重启)。
  • 把数据库密码、AK/SK 直接写进 application.yml 并提交 Git(Day 12 系统解决)。

Day 9 · IoC / DI:Spring 的心脏

所属阶段:Week 2 · Spring Boot 与数据访问 | 建议时长:3h | 进度:第 9 / 30 天

今日导语:控制反转与依赖注入是 Spring 全部能力的地基。今天要真正理解:对象是谁创建的、依赖怎么填进去、Bean 何时生何时死。理解之后,你才能解释“为什么这个注解没生效”“为什么循环依赖报错”,而不是靠试。

前端视角:直觉映射:Spring 容器 ≈ 一个全局 DI 注册表(类似前端的 provide/inject 容器)。@Bean ≈ useFactory 手动注册;字段注入 vs 构造器注入,就像组件里直接 import 单例 vs 通过参数传入——后者可测性高出一个量级。

🎯 今日目标

  • 理解 IoC 容器与依赖注入,掌握构造器注入的写法与理由
  • 分清 @Component / @Service / @Repository / @Controller / @Configuration / @Bean
  • 掌握 Bean 作用域与生命周期,能配置第三方对象并排查循环依赖

📌 控制反转与依赖注入

  • IoC:对象的创建与组装交给容器,而不是你在代码里 new。DI:依赖由容器注入进来,而不是你主动去拿(避免 ServiceLocator 反模式)。
    • Spring 的实现:BeanFactory(基础容器,懒加载)与 ApplicationContext(增强容器,预加载单例 + 事件 + 国际化 + AOP)。
    • 我们日常说的“Spring 容器”一般指 ApplicationContext。
  • 好处:① 解耦(依赖接口而非实现);② 可测(单测里塞 mock 实现);③ 统一生命周期与增强(AOP 代理就在容器里织入)。
  • 三种注入方式:构造器注入(推荐)、Setter 注入(可选依赖)、字段注入(不推荐)。
    • 构造器注入是 Spring 官方推荐的唯一方式(Spring 文档明确建议),尤其在 required 依赖场景。
    • Setter 注入适合“可选依赖”与循环依赖的历史遗留代码,新代码少用。
  • 推荐构造器注入的理由:依赖不可变(final)、对象创建完即处于合法状态、循环依赖在启动时就暴露(Fail Fast)、单测里直接 new 即可,无需反射。
    • Spring 官方文档的原话:构造器注入让依赖成为不可变的、完全初始化后的组件,并能在启动时暴露循环依赖。
    • 团队规范建议:禁止字段注入(可用 ArchUnit 或 Checkstyle 规则强制)。
  • Lombok @RequiredArgsConstructor 一行生成构造器;Spring 4.3+ 对单构造器类可省略 @Autowired。

📌 组件注解与分层

  • @Component 通用;@Service 业务层(语义标注,便于 AOP 与阅读);@Repository 数据访问层(额外把 JPA/MyBatis 异常转成 Spring 的 DataAccessException);@Controller/@RestController Web 层;@Configuration 配置类,内部用 @Bean 定义第三方对象。
  • 它们都是 @Component 的“特化”,功能上都能被扫描到,区别在于语义、切点与框架附加处理。别把所有东西都标 @Component。
    • @Repository 的附加价值:把 JPA/MyBatis 的持久化异常转换为 Spring 统一的 DataAccessException(这样上层不用关心用的是哪个 ORM)。
    • @Service 目前没有附加行为,主要是语义与 AOP 切点(如 @within(org.springframework.stereotype.Service))。
  • @Bean 用在配置类里:把无法改源码的第三方对象(RestClient、ObjectMapper、线程池、RedisTemplate)交给容器管理,方法参数会自动注入。
    • @Bean 方法默认方法名即 Bean 名,可用 @Bean("name") 指定;默认是单例,多次调用返回同一个实例(因为容器缓存)。
    • 在配置类里调用其他 @Bean 方法会被 CGLIB 拦截并走容器(保证单例),这就是 @Configuration 的 proxyBeanMethods 机制(设为 false 可提速,但方法间调用不再受管)。
  • 条件化注册:@ConditionalOnProperty、@Profile("dev")、@ConditionalOnMissingBean——多环境切换实现的常用手段。
    • 组合使用:@ConditionalOnMissingBean(用户优先)、@ConditionalOnProperty(name="feature.x", havingValue="true", matchIfMissing=true)(功能开关)。
    • 功能开关是灰度发布的利器:配置打开才注册新实现。
  • 组件扫描范围由启动类所在包决定(Day 8)。

📌 作用域与生命周期

  • 作用域:singleton(默认,一个容器一个实例)、prototype(每次注入新建)、request / session(Web 场景)、application。绝大多数 Bean 是单例。
    • prototype 的销毁:容器只负责创建,不负责调用 @PreDestroy(需要自己释放资源),所以用不好会泄漏。
    • request/session 作用域只在 Web 容器(ApplicationContext)下可用,且需要代理或 ObjectProvider 注入到单例中。
    • 绝大多数情况用默认的 singleton;引入其他作用域前先问“这个状态真的需要隔离吗”。
  • 单例不等于线程安全:不要在单例 Bean 里放可变成员变量(List、计数器、SimpleDateFormat),需要时用 ThreadLocal、AtomicInteger 或 ConcurrentHashMap。
    • 判断方法:找出 Bean 里所有非 final 的实例字段,问它会不会被多个请求同时读写。
    • 无状态化改造:把可变状态移到方法局部变量、数据库/Redis、或 ThreadLocal(务必清理)。
  • 生命周期:实例化 → 属性注入 → 初始化(@PostConstruct / InitializingBean / initMethod)→ 使用 → 销毁(@PreDestroy / destroyMethod)。
    • 三个初始化入口的执行顺序:@PostConstruct(JSR-250,最先)→ InitializingBean.afterPropertiesSet → @Bean(initMethod=...)。
    • 销毁顺序相反:@PreDestroy → DisposableBean.destroy → destroyMethod。
    • 实际项目用 @PostConstruct/@PreDestroy 即可(不依赖 Spring API)。
  • 扩展点:BeanPostProcessor(AOP 代理就是在这里生成的)、ApplicationContextAware、FactoryBean。理解它们就理解了 Spring 的很多“魔法”。
    • BeanPostProcessor:对所有 Bean 生效,AOP、@ConfigurationProperties 绑定、@Autowired 处理都靠它。
    • BeanFactoryPostProcessor:在 BeanDefinition 阶段介入(如 PropertySourcesPlaceholderConfigurer 处理 ${} 占位符)。
    • 自定义扩展点要谨慎:它会影响所有 Bean,出问题影响面极大。
  • 循环依赖:构造器注入会直接启动失败(Fail Fast,是好事);字段注入能靠三级缓存勉强启动但掩盖设计问题。真正解法是重构(抽取第三个服务、用事件解耦),而不是 @Lazy 硬绕。
    • Spring Boot 2.6+ 默认禁止循环依赖(spring.main.allow-circular-references=false),启动直接失败——这是官方在“逼你重构”。
    • 检测手段:启动日志、IDEA 的依赖分析、架构守护工具(ArchUnit)写测试禁止跨层循环依赖。

📌 Bean 的创建过程与常见误区

  • 一个 Bean 的诞生全流程
    • ① 扫描/解析 BeanDefinition → ② 推断构造器 → ③ 实例化(反射 new) → ④ 属性注入(@Autowired/@Value) → ⑤ Aware 回调(BeanName/BeanFactory/ApplicationContext) → ⑥ BeanPostProcessor 前置处理 → ⑦ 初始化(@PostConstruct / InitializingBean) → ⑧ BeanPostProcessor 后置处理(此处生成 AOP 代理) → ⑨ 放入单例池 → ⑩ 容器关闭时 @PreDestroy。
    • 关键认知:AOP 代理是在第 ⑧ 步才生成的——这解释了为什么构造器里调用自身方法不会被增强。
  • 常见误区清单
    • 误区一:构造器里用 @Autowired 字段 —— 此时注入还没发生,字段是 null。
    • 误区二:@PostConstruct 里做重 IO —— 拖慢启动,可能被健康检查判死。
    • 误区三:在 Bean 里持有 ApplicationContext 到处 getBean —— 退化成 ServiceLocator,失去 DI 意义。
    • 误区四:认为 @Component 越多越好 —— 每个 Bean 都要扫描、实例化、可能被代理,启动与内存都有成本。
  • 同类型多个实现的注入技巧
    • 用 @Qualifier("beanName") 指定;或用 @Primary 标记默认实现。
    • 更优雅:注入 Map<String, Interface>(key 是 Bean 名)或 List,实现策略模式的自动装配(所有实现一次拿到)。
    • 按条件选择:@ConditionalOnProperty + 不同 @Bean,或 @Profile 区分环境。
  • Bean 的线程安全边界
    • 容器启动时的单例创建是单线程的,无需担心;运行期的并发访问才是问题。
    • 有状态 Bean 的正确做法:把状态外置到数据库/Redis,或用 ThreadLocal(务必清理),或改为 prototype/request 作用域。
  • 调试技巧:给 Bean 加日志或用 /actuator/beans 查看它是否被创建、属于哪个类(是不是代理类 $Proxy / $$CGLIB)。

📌 深入:三级缓存与 Bean 创建源码视角

  • 三个缓存分别是什么
    • singletonObjects(一级):成品单例,已完成初始化,对外提供。
    • earlySingletonObjects(二级):半成品的早期引用(已实例化、未填充属性),用于解决循环依赖。
    • singletonFactories(三级):存的是 ObjectFactory(工厂),调用它才能拿到(可能是代理对象的)早期引用——这一级是为了 AOP 代理而存在。
  • 循环依赖的解法流程
    • A 创建 → 实例化后把自己(ObjectFactory)放进三级缓存 → 填充属性时发现需要 B → 创建 B → B 填充属性时需要 A → 从三级缓存拿到 A 的早期引用并放入二级缓存 → B 创建完成 → A 拿到 B → A 继续初始化完成 → 移入一级缓存。
    • 为什么需要三级而不是两级:如果 A 需要被 AOP 代理,那么注入给 B 的必须是代理对象;三级缓存的 ObjectFactory 会在被调用时才决定返回原始对象还是代理对象(getEarlyBeanReference)。
    • 构造器注入无法解决:对象都没实例化完,无法提前暴露引用 → 直接抛 BeanCurrentlyInCreationException。
  • 为什么 Spring Boot 2.6 起默认禁止循环依赖
    • 循环依赖是设计问题(职责边界不清)的信号,允许它只会让问题被掩盖并越拖越久。
    • 禁止后启动即失败,倒逼团队重构(这也是“Fail Fast”理念的体现)。
    • 确有历史包袱可临时开 spring.main.allow-circular-references=true,但应作为技术债登记。
  • 注入技巧:同类型多实现
    • Map 注入:@Autowired Map<String, Notifier> map —— key 是 Bean 名,value 是实现,天然支持策略模式(按 name 选择)。
    • List 注入:@Autowired List list —— 拿到所有实现,可依次执行(如多个校验器链)。
    • 指定实现:@Qualifier("smsNotifier") 或 @Resource(name="smsNotifier")。
  • 作用域代理与 prototype 注入
    • 问题:单例 Bean 注入 prototype Bean 时,只注入一次(永远是同一个实例)。
    • 解法:@Scope(value="prototype", proxyMode=ScopedProxyMode.TARGET_CLASS) 注入一个代理,每次调用时再取新实例;或用 ObjectProvider.getObject()/@Lookup 方法。
  • 调试技巧:给 Bean 打印 getClass().getName(),出现 SpringCGLIBSpringCGLIB 或 $Proxy 说明它被代理了(事务、AOP、作用域代理都会产生代理)。

💻 三种注入方式对比与推荐写法

// ✅ 推荐:构造器注入(final 字段,Lombok 生成构造器)
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepo repo;
    private final PasswordEncoder encoder;
    private final ApplicationEventPublisher events;

    public UserVo create(CreateUserCmd cmd) {
        var user = User.of(cmd.email(), encoder.encode(cmd.rawPassword()));
        var saved = repo.save(user);
        events.publishEvent(new UserCreatedEvent(saved.getId()));
        return UserVo.from(saved);
    }
}

// ⚠️ 可用但要小心:Setter 注入(适合可选依赖)
@Service
public class NotifyService {
    private SmsClient smsClient;
    @Autowired(required = false)
    public void setSmsClient(SmsClient c) { this.smsClient = c; }
}

// ❌ 不推荐:字段注入(难测试、可能为 null、隐藏循环依赖)
@Service
public class BadService {
    @Autowired private UserRepo repo;
}

// 配置类:把第三方对象交给容器
@Configuration
public class InfraConfig {

    @Bean
    public RestClient restClient(@Value("${api.base-url}") String base) {
        return RestClient.builder().baseUrl(base).build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();     // 单例,全局复用
    }

    @Bean
    @ConditionalOnProperty(name = "feature.sms.enabled", havingValue = "true")
    public SmsClient smsClient() { return new AliyunSmsClient(); }
}

💻 生命周期与线程安全

@Service
public class CacheWarmer {

    @PostConstruct                 // 构造完成、依赖注入后执行
    public void init() {
        log.info("预热缓存开始");
    }

    @PreDestroy                    // 容器关闭时执行(配合优雅停机)
    public void destroy() {
        log.info("释放资源");
    }
}

// 反例:单例里放可变成员 = 线程不安全
@Service
public class CounterBad {
    private int count = 0;                       // 多线程 ++ 会丢失更新
    public void hit() { count++; }
}

// 正确:用原子类或并发容器
@Service
public class CounterGood {
    private final AtomicInteger count = new AtomicInteger();
    public void hit() { count.incrementAndGet(); }
}

💻 多实现装配:策略模式与条件切换

public interface Notifier {
    String type();                 // 每种实现声明自己的类型
    void send(String to, String content);
}

@Service("sms")  class SmsNotifier  implements Notifier {
    @Override public String type() { return "sms"; }
    @Override public void send(String to, String c) { /* 短信网关 */ }
}
@Service("mail") class MailNotifier implements Notifier {
    @Override public String type() { return "mail"; }
    @Override public void send(String to, String c) { /* 邮件服务 */ }
}

// 策略模式:Spring 自动把「Bean 名 → 实现」注入成 Map
@Service
@RequiredArgsConstructor
public class NotifyService {
    private final Map<String, Notifier> notifiers;      // key = Bean 名

    public void send(String type, String to, String content) {
        Notifier n = Optional.ofNullable(notifiers.get(type))
            .orElseThrow(() -> new BizException(1000, "不支持的通知类型: " + type));
        n.send(to, content);
    }
}

// 条件装配:只在配置开启时注册(功能开关)
@Configuration
class NotifyConfig {
    @Bean
    @ConditionalOnProperty(name = "notify.sms.enabled", havingValue = "true")
    public SmsClient smsClient(SmsProperties p) { return new AliyunSmsClient(p); }
}

// ObjectProvider:需要时才取(避免启动期强依赖、解决 prototype 注入)
@RequiredArgsConstructor
@Service
class ReportService {
    private final ObjectProvider<ReportExporter> exporter;
    public void export() { exporter.ifAvailable(ReportExporter::run); }
}

🛠 动手任务

  • 把 RestClient(或 ObjectMapper)通过 @Bean 注册并注入 Service,然后写单测用 Mockito 替换它,体会可测性的提升。
  • 故意制造一次循环依赖(A→B→A),分别用构造器注入和字段注入跑一遍,观察启动报错差异,再用“抽取第三个服务”重构掉。
  • 写一个 @PostConstruct 预热的 Bean,并在关闭应用时观察 @PreDestroy 是否执行(配合 server.shutdown=graceful)。

✅ 自测题与参考答案(4 题)

Q1. 为什么字段注入会让单元测试变难?

参考答案(点击展开)

字段注入的依赖是 private 且没有构造器/Setter 入口,单测只能通过反射(@InjectMocks)或启动整个 Spring 容器(@SpringBootTest,慢)来塞入 mock;构造器注入下测试代码就是 new UserService(mockRepo, mockEncoder),依赖一目了然、编译期保证不漏。字段注入还有三个问题:① 依赖可以为 null(对象处于半初始化状态);② 无法声明 final,可能被反射改掉;③ 掩盖循环依赖,让设计问题推迟到运行期爆发。这也是 Spring 官方文档与主流规范推荐构造器注入的原因。

Q2. 单例 Bean 里放 ArrayList 成员变量累加数据会出什么问题?

参考答案(点击展开)

单例 Bean 全容器只有一个实例,被所有请求线程共享。ArrayList 非线程安全,并发 add 会导致:① 元素丢失(size 与扩容竞争);② 数组越界或 ConcurrentModificationException;③ 内存无限增长(相当于永不释放的缓存),最终 OOM。正确做法:① 需要线程安全用 ConcurrentLinkedQueue / CopyOnWriteArrayList / Collections.synchronizedList;② 计数用 AtomicInteger / LongAdder;③ 按请求隔离用 request 作用域或方法内局部变量;④ 需要缓存就用 Redis / Caffeine。核心原则:单例 Bean 应当是无状态的。

Q3. 循环依赖是怎么产生的?三级缓存能解决所有情况吗?

参考答案(点击展开)

产生原因:A 需要 B、B 又需要 A,常见于 Service 互相调用(订单服务调库存、库存回调订单)。Spring 的三级缓存(singletonObjects / earlySingletonObjects / singletonFactories)能解决“字段注入且非代理”的循环依赖——提前暴露未初始化完成的早期引用。但它解决不了:① 构造器注入的循环依赖(对象都没创建完,无法提前暴露,直接 BeanCurrentlyInCreationException);② 涉及 AOP 代理的复杂场景(需要三级缓存的 ObjectFactory 才能拿到代理对象)。真正的解法在设计层:抽取公共逻辑到第三个 Service、用领域事件解耦、或改为 Setter 注入。@Lazy 只是把问题推迟。

Q4. 构造器注入报循环依赖,除了 @Lazy 还有哪些解法?各自的代价?

参考答案(点击展开)

① 抽取第三个服务(最推荐):把 A、B 共同依赖的那部分逻辑抽成 C,A、B 都依赖 C,循环消失;代价是多一个类,但职责更清晰。② 事件解耦:A 不直接调 B,而是发布事件,B 监听处理(ApplicationEvent 或 MQ);代价是流程变得隐式、排查要靠事件日志。③ Setter/字段注入:能启动但不推荐,只是把问题藏起来。④ ObjectProvider 延迟获取:用到时再 getObject(),打破启动期的强依赖;代价是依赖获取散落在代码里。⑤ @Lazy:注入一个代理,首次调用时才真正创建;代价是可能把启动失败推迟到运行期。优先级:① > ② > ⑤ > ④ > ③。工程上还建议用 ArchUnit 写一条测试禁止跨模块循环依赖,从源头防住。

⚠️ 前端容易踩的坑

  • @Autowired 字段注入 + 循环依赖:启动能过但架构已经腐化,后续拆服务寸步难行。
  • 在 @Bean 方法里手动 new 一堆依赖,把容器管理的好处全丢掉(无法被 AOP 增强、无法替换实现)。
  • 单例里注入 prototype Bean 却期待每次都新建(实际只注入一次)——用 ObjectProvider.getObject() 或代理作用域。
  • 在 @PostConstruct 里做重 IO(拉全量数据),导致启动超时被容器杀掉;应异步预热或加超时保护。

Day 10 · 设计 REST API:Controller、DTO 与统一响应

所属阶段:Week 2 · Spring Boot 与数据访问 | 建议时长:3h | 进度:第 10 / 30 天

今日导语:这是你最熟悉的一块——今天从“调用方”变成“设计方”。核心不是记住注解,而是建立契约意识:URL 怎么设计、字段怎么命名、分页怎么返回、空值怎么处理。把你当年吐槽过的接口问题,全部在自己这里终结。

前端视角:对照前端经验:字段别一会儿驼峰一会儿下划线、分页要有 total、空列表返回 [] 而不是 null、时间统一 ISO-8601、错误信息要能直接高亮到表单字段。今天的目标就是让你自己成为“不坑队友”的后端。

🎯 今日目标

  • 用 @RestController 写出规范的资源 CRUD 接口
  • 分清 @RequestParam / @PathVariable / @RequestBody / @RequestHeader 的场景
  • 建立 Entity / DTO / VO 分层与统一响应体,定义好分页结构

📌 Controller 注解与参数绑定

  • @RestController = @Controller + @ResponseBody;@RequestMapping 定义前缀,@GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMapping 对应 HTTP 方法。
  • @PathVariable 取路径参数;@RequestParam 取查询串(可设 defaultValue 与 required=false);@RequestBody 接收 JSON(Jackson 反序列化);@RequestHeader 取请求头。
    • 路径参数必传且不能为空,缺参会 404(不匹配路由);查询参数可设 defaultValue。
    • 同名技巧:@GetMapping("/{id}") + @PathVariable Long id,参数名要一致(或显式 @PathVariable("id"))。
    • 日期/枚举参数需要转换器:@DateTimeFormat(pattern="yyyy-MM-dd"),或全局注册 Converter。
  • 返回:对象会被 Jackson 序列化;想控制状态码用 @ResponseStatus 或返回 ResponseEntity。
  • 参数较多时用 query 对象接收(Spring 会把 ?name=x&age=1 绑定到 UserQuery 字段),比一长串 @RequestParam 清爽。
  • JSON 控制:@JsonIgnore(不返回)、@JsonFormat(日期格式)、@JsonProperty(改名)、全局 default-property-inclusion=non_null。

📌 REST 设计约定

  • 资源用复数名词:/api/users、/api/users/{id}/orders;URL 里不要出现动词(/getUserById 是反例),动作靠 HTTP 方法表达。
    • 资源层级:/users/{id}/orders 表示“某用户的订单”;层级不宜超过 3 层。
    • 非 CRUD 的动作如何表达:用子资源(POST /orders/{id}/cancel)或动名词(POST /orders/{id}/payment),并保持团队一致。
  • 状态码语义:200 查询成功、201 创建成功、204 删除成功(无 body)、400 参数错误、401 未登录、403 无权限、404 不存在、409 冲突、429 限流、500 系统异常。
    • 204 vs 200:204 无响应体,前端 axios 拿到的 data 是空串,容易误判;若需要返回内容就用 200。
    • 401 vs 403:401 未认证(token 缺失/过期),403 已认证但权限不足;前端据此决定跳登录还是提示无权限。
  • 幂等性:GET/PUT/DELETE 幂等;POST 不幂等——需客户端传幂等键防重复提交(Day 17 落地)。
    • 实现幂等的三件套:客户端生成幂等键(UUID)→ 服务端唯一索引(UNIQUE(request_id))→ 冲突时返回首次结果而不是报错。
    • 幂等键要放在稳定的位置(请求头 Idempotency-Key 或 body 字段),并在文档中写明。
  • 版本:URL 前缀 /api/v1 或请求头版本;破坏性变更走新版本,旧版本灰度下线。
  • 批量与分页:列表必须分页且有 size 上限(如 max 100);批量操作要有数量上限。

📌 分层:Entity / DTO / VO

  • Entity(贴着表结构)≠ DTO/Req(入参)≠ VO(出参)。直接返回 Entity 会:① 泄露敏感字段;② 表结构一改接口就变;③ 双向关联导致 JSON 循环引用栈溢出。
  • 转换:手写 Mapper 方法、MapStruct(编译期生成,性能最好)、BeanUtil.copyProperties(反射,谨慎用在热路径)。推荐 MapStruct 或显式 from()。
    • MapStruct 原理:编译期生成 XxxMapperImpl(普通 Java 代码,零反射,性能等同手写),字段同名自动映射,支持自定义表达式与默认值。
    • BeanUtil.copyProperties 是反射实现,字段多时性能差且静默忽略类型不匹配——只在低频场景用。
  • 统一响应:{code, data, msg},code=0 表示成功;HTTP 状态码表达协议层、body 里的 code 表达业务层,二者配合。
    • 争议点:HTTP 状态码已经能表达成功失败,body 里还要 code 吗?实践结论是要——因为业务错误(库存不足、余额不够)无法映射为标准 HTTP 码。
    • 约定:HTTP 状态码表达“协议层”,body 的 code 表达“业务层”,前端先判 HTTP 再判 code。
  • 分页体:{list, total, page, size};大数据量或深翻页改用游标 {list, nextCursor}(Day 14 讲原因)。
  • 空值策略:集合返回 [] 不返回 null;对象不存在返回 404 而非 null;用 @JsonInclude 统一控制字段。

📌 序列化细节与 Jackson 配置

  • Jackson 常用配置
    • spring.jackson.default-property-inclusion=non_null:null 字段不返回(前端不用判空,但要注意“显式 null”语义丢失)。
    • spring.jackson.date-format + time-zone:统一日期格式;WRITE_DATES_AS_TIMESTAMPS=false 避免数组格式。
    • FAIL_ON_UNKNOWN_PROPERTIES=false:接收第三方 JSON 时忽略多余字段(Spring Boot 默认已关闭)。
  • 常用注解
    • @JsonIgnore(不序列化)、@JsonProperty(改名)、@JsonFormat(日期/数字格式)、@JsonInclude(条件包含)。
    • @JsonView 可做同一对象的多视图(列表简版/详情完整版);@JsonSerialize(using=...) 自定义序列化(脱敏)。
    • 枚举:@JsonValue 指定序列化值、@JsonCreator 指定反序列化入口。
  • 序列化陷阱
    • 循环引用:双向关联(User↔Order)序列化会栈溢出 → 用 @JsonIgnore、@JsonManagedReference/@JsonBackReference,或干脆用 VO 切断。
    • BigDecimal 序列化:默认输出数字(可能丢精度或被前端当数字运算),金额建议用字符串输出。
    • Long 精度:雪花 ID 是 19 位,超过 JS 的 Number 安全范围(2^53)→ 必须序列化为字符串,否则前端拿到的是错误 ID。
  • 大响应体的优化
    • 开启 GZIP:server.compression.enabled=true(对 JSON 压缩率很高)。
    • 字段裁剪:返回 VO 而不是 Entity;列表接口只返回必要字段。
    • 分页上限:size 硬上限(如 100),避免一次返回十万条。
  • 响应压缩与缓存头:静态资源与不常变的接口加 Cache-Control,配合 ETag 可显著降低带宽与后端压力。

💻 完整 CRUD Controller

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService service;

    @GetMapping
    public R<PageVo<UserVo>> list(@RequestParam(defaultValue = "1") int page,
                                  @RequestParam(defaultValue = "20") int size,
                                  @RequestParam(required = false) String keyword) {
        return R.ok(service.page(page, Math.min(size, 100), keyword));   // size 上限保护
    }

    @GetMapping("/{id}")
    public R<UserVo> detail(@PathVariable Long id) {
        return R.ok(service.detail(id));
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)                 // 201
    public R<Long> create(@RequestBody @Valid CreateUserReq req) {
        return R.ok(service.create(req));
    }

    @PutMapping("/{id}")
    public R<Void> update(@PathVariable Long id, @RequestBody @Valid UpdateUserReq req) {
        service.update(id, req);
        return R.ok();
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)              // 204,无 body
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

💻 DTO / VO / MapStruct 转换

// 入参 DTO(校验在 Day 11 生效)
public record CreateUserReq(
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8, max = 32) String password,
    @NotNull @Min(0) @Max(120) Integer age
) {}

// 出参 VO:只暴露该暴露的字段
public record UserVo(Long id, String email, String nickname,
                     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt) {
    public static UserVo from(User u) {
        return new UserVo(u.getId(), u.getEmail(), u.getNickname(), u.getCreatedAt());
    }
}

// MapStruct:编译期生成实现类,零反射
@Mapper(componentModel = "spring")
public interface UserMapper {
    UserVo toVo(User user);
    List<UserVo> toVoList(List<User> users);
    User from(CreateUserReq req);      // 字段同名自动映射
}

// 需要自定义响应头时用 ResponseEntity
@GetMapping("/{id}/avatar")
public ResponseEntity<byte[]> avatar(@PathVariable Long id) {
    return ResponseEntity.ok()
        .header(HttpHeaders.CACHE_CONTROL, "max-age=3600")
        .contentType(MediaType.IMAGE_PNG)
        .body(service.avatar(id));
}

🛠 动手任务

  • 实现一个资源(用户或文章)的完整 5 个接口,用 IDEA 的 .http 文件或 curl 全部调通,把请求与响应贴进笔记。
  • 站在前端视角写一份“接口吐槽清单”(命名不一致、null、无 total、时间格式混乱…),然后回头逐个改掉自己的接口。
  • 引入 MapStruct(或手写 Mapper),把 Entity→VO 的转换从 Controller 里彻底抽走。

✅ 自测题与参考答案(3 题)

Q1. POST /users 与 PUT /users/{id} 的语义差异?什么时候用 201、什么时候用 204?

参考答案(点击展开)

POST /users 表示“在集合中创建新资源”,非幂等(重复调用会创建多个),成功返回 201 Created,通常在 Location 头带上新资源 URL。PUT /users/{id} 表示整体替换(或按 id 创建)该资源,幂等(重复调用结果一致),成功返回 200(带 body)或 204(不带 body)。PATCH 用于局部更新。204 No Content 用于“成功但无需返回内容”的场景,典型是 DELETE 成功;注意 204 不能带响应体,前端拿不到 data,所以若要返回“删除了几条”就用 200 + body。

Q2. 为什么不要直接把 JPA 实体序列化返回给前端?

参考答案(点击展开)

四个理由:① 泄露敏感字段(password、salt、内部状态、成本价),加 @JsonIgnore 也容易漏;② 耦合——数据库字段改名/加字段,接口契约跟着变;③ 懒加载与循环引用——序列化时触发懒加载产生 N+1 查询,双向关联(User↔Order)还会无限递归导致栈溢出;④ 表达力不足——前端需要的常是组合视图(订单里带商品名、状态文案),实体结构天然不匹配。正确做法是 VO/DTO 显式建模 + MapStruct 或手写转换,接口契约由你主动定义而非被动跟随表结构。

Q3. 为什么分页要返回 {list, total, page, size} 而不是直接返回数组?

参考答案(点击展开)

前端分页组件需要知道总条数才能渲染页码、判断“是否还有下一页”;只返回数组时前端无法区分“最后一页”与“没有更多数据”,也无法显示“共 238 条”。返回 total 才能支持跳页。代价是每次要查 count,优化手段:① 深翻页用游标(cursor)替代 offset,只返回 nextCursor/hasMore;② 大数据量用估算总数或干脆只做“加载更多”;③ 缓存 count。另外 list 必须返回 [] 而不是 null,前端 .map 才不会崩。

⚠️ 前端容易踩的坑

  • 返回 null 导致前端 .map 崩溃——永远返回空集合或默认值(PageVo 里用 List.of() 兜底)。
  • URL 里用动词(/getUserById),或一个接口既查询又修改。
  • 分页不加 size 上限,被 ?size=1000000 一击打垮数据库。
  • PUT 当 PATCH 用(前端只传改动字段,后端把未传字段全置空)。
  • 在 Controller 里写业务逻辑(Service 层形同虚设),导致逻辑无法复用、无法测试、无法加事务。

Day 11 · 参数校验与全局异常处理

所属阶段:Week 2 · Spring Boot 与数据访问 | 建议时长:2-3h | 进度:第 11 / 30 天

今日导语:前端校验是体验,后端校验是安全——任何来自客户端的数据都不可信。今天要把校验做成“声明式”的,并把所有异常收拢到一个出口,让前端拿到结构一致、带上字段级信息的错误响应。这是接口“专业度”的分水岭。

前端视角:对照前端:你写过的表单 rules(required/pattern/min)在后端同样要写一遍,只是写在 DTO 字段上。全局异常处理器 ≈ axios 响应拦截器里的统一错误处理,只是它同时决定了日志怎么记、用户看到什么。

🎯 今日目标

  • 用 Bean Validation 做声明式校验,能处理嵌套对象与分组校验
  • 用 @RestControllerAdvice 建立统一异常出口与错误码体系
  • 设计前端可消费的错误结构(含字段级错误,支持表单高亮)

📌 Bean Validation(JSR-380)

  • 常用注解:@NotNull、@NotBlank(字符串,会去空白)、@NotEmpty(集合/数组)、@Size(min,max)、@Min/@Max、@DecimalMin、@Pattern(regexp)、@Email、@Past/@Future、@Positive。
    • @NotNull 只判 null,不判空串;字符串用 @NotBlank(会 trim 后判空),集合/数组用 @NotEmpty——三者不能互换。
    • @Size 对字符串是长度、对集合是元素个数;@Length 是 Hibernate 扩展,功能类似。
    • @Pattern 注意正则要匹配整个输入(Java 的 matches 语义),写手机号是 ^1[3-9]\d{9}$。
  • 生效条件:@RequestBody 前必须加 @Valid(或 @Validated);嵌套对象字段上加 @Valid 才会级联校验;单个方法参数(如 @RequestParam)校验要在类上加 @Validated。
    • Spring Boot 3 使用 jakarta.validation.(Boot 2 是 javax.validation.),混用会导致注解完全不生效且无任何报错。
    • 依赖:spring-boot-starter-validation(Boot 2.3 之后从 web starter 里拆出来了,必须单独引)。
  • 分组校验:定义 interface CreateGroup / UpdateGroup,注解里写 groups = {CreateGroup.class},@Validated(UpdateGroup.class) 激活——解决“新增时 id 为空、更新时 id 必填”的差异。
    • 未指定 groups 的注解属于 Default 组;@Validated(UpdateGroup.class) 不会校验 Default 组,需要显式写成 @Validated({Default.class, UpdateGroup.class})。
    • 实践建议:分组只在“同一 DTO 的新增/更新差异很大”时用,否则拆成两个 DTO 更清晰。
  • 自定义校验:实现 ConstraintValidator<Annotation, T> + 自定义注解,例如 @Phone、@EnumValue。
  • 校验失败抛 MethodArgumentNotValidException,需要在全局异常处理器里转成结构化响应。

📌 全局异常处理

  • @RestControllerAdvice(= @ControllerAdvice + @ResponseBody)+ @ExceptionHandler,按异常类型从具体到宽泛匹配。
    • 作用范围:可用 @RestControllerAdvice(basePackages="com.demo.api") 限定包,避免影响其他模块的异常处理。
    • 与 @ControllerAdvice 的区别:前者默认给所有 @ExceptionHandler 加上 @ResponseBody,适合纯 API 服务。
  • 分类处理:业务异常(BizException)记 WARN 返回业务码;参数校验异常返回字段级错误;系统异常记 ERROR 带 traceId,返回通用文案。
  • 错误码规范:分段设计(1xxx 参数、2xxx 资源、3xxx 权限、4xxx 业务、5xxx 系统),配一个 ErrorCode 枚举并在文档里列出。
    • 错误码要给前端可读文案 + 机器可判断的 code:前端按 code 分支(如 3001 跳登录),按 msg 展示。
    • 国际化:msg 可放消息模板(i18n),按请求头 Accept-Language 返回。
  • 响应结构建议:{code, msg, traceId, errors:[{field, message}]}。field 用 JSON 路径(如 address.city),前端可直接映射到表单项。
  • 安全:不要把 e.getMessage() 或堆栈直接返回给客户端,避免泄露 SQL、表结构、内网 IP;生产环境关闭 server.error.include-stacktrace。
    • Spring Boot 配置:server.error.include-message=never / include-stacktrace=never(默认已较安全,但自定义异常处理器里要自查)。
    • 给用户的文案要“能指导行动”:不说“SQL 语法错误”,说“操作失败,请稍后重试(traceId: xxx)”。

📌 日志与可排查性

  • 业务异常:WARN 级别 + 精简信息(不打印完整堆栈,因为是可预期的);系统异常:ERROR 级别 + 完整堆栈 + traceId。
  • 用 MDC 写入 traceId 并在响应头回传 X-Trace-Id,用户报障时凭这个 ID 就能在日志里定位整条链路(Day 24 完整实现)。
  • 异常消息带上下文(userId、orderId、skuId),但脱敏敏感字段。
  • 不要在每个 Controller 方法里 try/catch——那是把同一件事写 50 遍;也不要 catch 后返回 R.ok(null),前端会误判成功。

📌 校验的边界与进阶

  • 校验分三层,职责不同
    • 格式校验(Controller 层):非空、长度、正则、范围——用 Bean Validation,失败返回 400 与字段错误。
    • 业务规则校验(Service 层):余额够不够、状态能不能流转——用 BizException + 错误码,失败返回 409/422。
    • 数据完整性校验(数据库层):唯一索引、外键、非空约束——最后一道防线,防止并发与脏数据绕过应用层。
  • 为什么数据库约束不能省
    • 并发场景:两个请求同时判断“邮箱不存在”后都插入,应用层校验挡不住,唯一索引才能兜住(DuplicateKeyException)。
    • 历史数据:代码改了但老数据不合规,约束能阻止新增脏数据;修数据要靠迁移脚本。
  • 自定义校验注解的写法
    • 定义注解:@Target(FIELD) @Retention(RUNTIME) @Constraint(validatedBy = PhoneValidator.class),必备 message/groups/payload 三个方法。
    • 实现 ConstraintValidator<Phone, String>:isValid 返回布尔,注意 null 应返回 true(交给 @NotBlank 处理)。
    • 复用:把注解打在 record 组件上即可,支持分组与嵌套。
  • 校验失败响应的前端消费方式
    • 返回结构:{code:400, msg:'参数校验失败', data:[{field:'email', message:'邮箱格式不正确'}]}。
    • 前端按 field 映射到表单项(Element Plus/Antd 的 Form 支持 setFields 批量设置错误信息),实现“哪个格子错了就红哪个”。
    • 嵌套对象用路径形式(address.city),前端按路径取值。
  • 批量接口要限制条数(如一次最多 100 条)并逐条校验,返回每行的错误位置(index + field)。

💻 全局异常处理器(可直接用)

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /** 业务异常:可预期,记 WARN 不打完整堆栈 */
    @ExceptionHandler(BizException.class)
    public R<Void> handleBiz(BizException e) {
        log.warn("biz error code={} msg={}", e.getCode(), e.getMessage());
        return R.fail(e.getCode(), e.getMessage());
    }

    /** @RequestBody 校验失败:返回字段级错误,供前端表单高亮 */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public R<List<FieldError>> handleValid(MethodArgumentNotValidException e) {
        var errors = e.getBindingResult().getFieldErrors().stream()
            .map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage()))
            .toList();
        return R.fail(400, "参数校验失败");
    }

    /** @RequestParam / 路径参数校验失败(类上加 @Validated) */
    @ExceptionHandler(ConstraintViolationException.class)
    public R<List<FieldError>> handleViolation(ConstraintViolationException e) {
        var errors = e.getConstraintViolations().stream()
            .map(v -> new FieldError(v.getPropertyPath().toString(), v.getMessage()))
            .toList();
        return R.fail(400, "参数校验失败");
    }

    /** 兜底:未知异常,记 traceId 便于排查,不暴露内部细节 */
    @ExceptionHandler(Exception.class)
    public R<Void> handleUnknown(Exception e, HttpServletRequest req) {
        String traceId = MDC.get("traceId");
        log.error("unknown error uri={} traceId={}", req.getRequestURI(), traceId, e);
        return R.fail(500, "服务开小差了,请稍后重试(traceId: " + traceId + ")");
    }

    public record FieldError(String field, String msg) {}
}

💻 DTO 校验与分组

public record CreateUserReq(
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    String email,

    @NotBlank @Size(min = 8, max = 32, message = "密码长度需为 8-32 位")
    String password,

    @NotNull @Min(0) @Max(120)
    Integer age,

    @Valid @NotNull
    AddressReq address          // 嵌套对象,必须加 @Valid 才级联校验
) {}

public record AddressReq(@NotBlank String city, @NotBlank String street) {}

// 分组:新增时 id 必须为空,更新时 id 必填
public interface CreateGroup {}
public interface UpdateGroup {}

public record UserCmd(
    @Null(groups = CreateGroup.class)
    @NotNull(groups = UpdateGroup.class)
    Long id,
    @NotBlank(groups = {CreateGroup.class, UpdateGroup.class}) String name
) {}

// Controller 上激活分组
@PostMapping
public R<Long> create(@RequestBody @Validated(CreateGroup.class) UserCmd cmd) { ... }

💻 统一错误码枚举

public enum ErrorCode {
    // 1xxx 参数与通用
    PARAM_ERROR(1000, "参数错误"),
    // 2xxx 资源
    NOT_FOUND(2001, "资源不存在"),
    // 3xxx 权限
    UNAUTHORIZED(3001, "未登录或登录已过期"),
    FORBIDDEN(3002, "无权限访问"),
    // 4xxx 业务
    STOCK_NOT_ENOUGH(4001, "库存不足"),
    ORDER_STATUS_INVALID(4002, "订单状态不允许该操作"),
    // 5xxx 系统
    SYSTEM_ERROR(5000, "系统异常,请稍后重试"),
    DOWNSTREAM_ERROR(5001, "依赖服务异常");

    private final int code;
    private final String msg;
    ErrorCode(int code, String msg) { this.code = code; this.msg = msg; }
    public int getCode() { return code; }
    public String getMsg() { return msg; }
}

🛠 动手任务

  • 给所有写接口加校验,用 Postman 传非法参数,验证响应包含字段级错误(field + message)。
  • 定义一份错误码枚举并在异常里使用,同时写一份“错误码对照表”给前端。
  • 实现一个自定义校验注解 @Phone(正则 + ConstraintValidator),并写单测覆盖合法/非法手机号。

✅ 自测题与参考答案(3 题)

Q1. 400、401、403、404、409、422、500 分别代表什么?我的接口该返回哪个?

参考答案(点击展开)

400 Bad Request:请求本身有问题(参数缺失、格式错误、校验失败),是最常用的客户端错误码;401 Unauthorized:未认证(没带 token 或 token 失效),前端应跳登录;403 Forbidden:已认证但无权限(普通用户访问管理员接口);404 Not Found:资源不存在(也可用于对无权限资源“假装不存在”,避免泄露存在性);409 Conflict:资源状态冲突(重复创建、订单已支付不能取消);422 Unprocessable Entity:语义正确但业务规则不允许(很多团队用它替代 400 表达业务校验失败,要与前端约定清楚);500 Internal Server Error:服务端未知异常。实践:参数格式错用 400,业务规则拒绝可用 400/409/422(团队统一),权限用 401/403,未知异常用 500 并记录 traceId。

Q2. 为什么不能在 Controller 的每个方法里 try/catch?

参考答案(点击展开)

① 重复:同一套“记日志 + 转错误码 + 返回结构”的逻辑要写几十遍,改一处要改几十处;② 不一致:每个人写的错误结构与日志格式不同,前端无法统一处理;③ 吞异常风险:catch 后返回 R.ok(null) 会让 HTTP 状态码变成 200,前端与监控系统都认为成功了,故障被完全隐藏;④ 事务失效:catch 掉异常后 @Transactional 认为方法正常结束并提交,造成“下单失败但库存已扣”的脏数据。正确做法是让异常自然上抛,由 @RestControllerAdvice 在边界统一处理:业务异常转成对应 code,系统异常记录堆栈并返回通用文案。

Q3. 校验注解加了却不生效,常见原因有哪些?

参考答案(点击展开)

① 忘了在参数前写 @Valid / @Validated(最常见);② 嵌套对象字段没加 @Valid,导致内部 DTO 的注解被忽略;③ 用 @RequestParam 做单参数校验却没在类上加 @Validated;④ 引入了 jakarta.validation 但依赖缺失(Spring Boot 3 用 jakarta.* 包,Boot 2 用 javax.*,混用会静默失效);⑤ 注解用错对象:@NotBlank 只能用于 CharSequence,用在 Long 上会抛 UnexpectedTypeException;⑥ 分组校验时激活的 group 与注解声明的 group 不匹配;⑦ 自己 new 的对象不走 Spring 校验(必须经 Controller 参数绑定才会触发)。

⚠️ 前端容易踩的坑

  • 把 e.getMessage() 直接返回给用户 → 泄露 SQL、表结构、内网地址(生产务必关闭 include-stacktrace、include-message)。
  • 捕获异常后返回 R.ok(null),前端以为成功(必须用 HTTP 状态码 + code 双重表达)。
  • 嵌套 DTO 忘了 @Valid,导致“校验看起来配了却没生效”。
  • 异常处理器顺序写错:把 @ExceptionHandler(Exception.class) 写在前面会“吃掉”所有具体异常(Spring 按最具体匹配,但同类中定义顺序要注意可读性与显式性)。

Day 12 · 配置管理:profiles、类型安全配置与密钥

所属阶段:Week 2 · Spring Boot 与数据访问 | 建议时长:2h | 进度:第 12 / 30 天

今日导语:配置的本质问题是:同一份代码要在不同环境用不同参数跑,且密钥绝不能进代码仓库。今天建立多环境配置体系,并把零散的 @Value 升级为类型安全的 @ConfigurationProperties,让配置可校验、可导航、可测试。

前端视角:对照前端:application.yml ≈ .env;profile ≈ .env.development / .env.production;环境变量覆盖 ≈ 部署平台里配的运行时变量。唯一铁律:密钥绝不进 Git,因为它会永远留在历史里。

🎯 今日目标

  • 掌握多环境 profile 切换与配置优先级
  • 用 @ConfigurationProperties 做类型安全配置(含校验与默认值)
  • 落实“配置与代码分离、密钥走环境变量”的原则

📌 多环境与 profile

  • 文件命名:application.yml(公共)+ application-dev.yml / application-test.yml / application-prod.yml;用 spring.profiles.active=prod 激活,或用 spring.config.activate.on-profile 在同一文件里分段。
    • Boot 2.4+ 的多文档写法:用 --- 分隔,并在每个块顶部写 spring.config.activate.on-profile: prod。
    • 旧写法 spring.profiles: prod 已废弃,混用会启动报错。
  • 激活方式(优先级从高到低):命令行参数 --spring.profiles.active、环境变量 SPRING_PROFILES_ACTIVE、配置文件、@ActiveProfiles(测试)。
    • 容器里最常用环境变量 SPRING_PROFILES_ACTIVE=prod;K8s 里写在 Deployment 的 env 或 ConfigMap。
    • 注意:命令行参数的优先级最高,会覆盖环境变量——排查时要先看启动命令。
  • Spring Boot 2.4+ 推荐用 spring.config.activate.on-profile,旧写法 spring.profiles 已废弃。
  • 分组:spring.profiles.group 可以把 prod 展开成 prod-db,prod-mq,prod-cache,方便组合。
  • 外部化:--spring.config.location 指定外部配置文件,容器部署时挂载 /config/application.yml 即可覆盖。

📌 配置优先级(高 → 低)

  • 命令行参数(--server.port=8081)> SPRING_APPLICATION_JSON > 环境变量(SPRING_DATASOURCE_URL)> application-{profile}.yml > application.yml > @PropertySource > 默认值。
    • SPRING_APPLICATION_JSON 适合一次性传多个配置:SPRING_APPLICATION_JSON='{"server":{"port":8081}}'。
    • K8s 里也可直接用 --spring.datasource.url=... 传参,但会暴露在进程列表,敏感值要用环境变量或 Secret。
  • 环境变量的松散绑定:SPRING_DATASOURCE_URL ↔ spring.datasource.url,下划线与点互通,容器里注入非常方便。
    • 规则:大写 + 下划线 ↔ 小写 + 点/横线;SPRING_DATASOURCE_URL、spring.datasource.url、spring.datasource-url 等价。
    • List/Map 的绑定:APP_SERVERS[0]=a、APP_MAP_KEY=value,写在环境变量里要注意转义。
  • 测试用 @TestPropertySource / @DynamicPropertySource 覆盖(Day 23 会用后者注入 Testcontainers 的地址)。
  • 排查“配置没生效”时,看 /actuator/env 端点能列出所有配置源及其优先级(Day 24)。

📌 类型安全配置与密钥

  • @ConfigurationProperties(prefix = "app.jwt") 把一组配置绑成对象(record 或 class),支持嵌套、List、Map、Duration、DataSize(如 10MB、30s 自动转换)。
    • 用 record 绑定时:字段需要有参构造,Boot 会用构造器绑定(推荐,支持不可变);用 class 则需要 setter。
    • 复杂类型:Duration 支持 30s/5m、DataSize 支持 10MB、List/Map/嵌套对象都支持。
    • 配合 spring-boot-configuration-processor 会生成元数据,IDE 在 yml 里可自动补全与提示。
  • 配合 @Validated + @NotBlank 做启动期校验:配置缺失直接启动失败,而不是运行到一半 NPE。
  • @Value("${x}") 适合零散取值;成组配置一律用 @ConfigurationProperties——可导航、可复用、可测试、IDE 有提示。
  • 密钥:走环境变量或配置中心(Nacos / Apollo / Vault);本地开发用 .env 或 IDE 运行配置,绝不提交。
    • 不要把密钥放进 git;CI 用平台 Secrets;生产用 KMS/Secrets Manager/密钥管理平台。
    • 日志与异常里绝不能出现密钥;给配置类写 toString 时要排除敏感字段(或干脆不打印整个配置对象)。
  • 敏感配置加密:Jasypt 或云厂商 KMS;日志里禁止打印配置对象(toString 会带上密钥)。
  • IDEA 提示:引入 spring-boot-configuration-processor 后,自定义配置在 yml 里有自动补全与文档提示。

📌 配置中心与动态刷新

  • 为什么需要配置中心
    • 问题:改一个限流阈值要重新打包发布,几十个实例逐个重启——不可用。
    • 配置中心(Nacos / Apollo / Spring Cloud Config)提供:集中管理、按环境/集群分组、动态推送、灰度、变更审计与回滚。
    • 代价:引入中间件与运维成本;小项目用“环境变量 + 重启”或“数据库配置表 + 定时刷新”也能活得很好。
  • 动态刷新的三种实现
    • @RefreshScope(Spring Cloud):Bean 上加注解,配置变更时重建该 Bean——简单但有坑(重建期间的请求、代理对象的引用)。
    • @ConfigurationProperties + 监听事件:配合 EnvironmentChangeEvent 或 Nacos 的自动刷新,推荐方式(无 Bean 重建)。
    • 自己实现:配置表 + 定时任务拉取 + 发布事件,适合不想引入配置中心的团队。
  • 配置变更的安全边界
    • 不是所有配置都该热更新:数据源地址、密钥、线程池核心参数变更需要重启才安全(或至少要有灰度与回滚)。
    • 可热更新的典型项:限流阈值、功能开关、超时时间、文案、灰度比例。
    • 所有变更都要有审计日志(谁、何时、改了什么)与一键回滚。
  • 本地开发体验
    • 提供 application-local.yml(gitignore)+ IDE 运行配置,或用 spring.profiles.active=local 加载本地覆盖。
    • README 里说明“最少需要配哪些环境变量”,并提供一份 .env.example。
  • 容器与 K8s 场景:ConfigMap 挂配置、Secret 存密钥;注意 ConfigMap 更新不会自动进容器(需要用 subPath 之外的挂载方式或重启)。

💻 类型安全配置(推荐写法)

@ConfigurationProperties(prefix = "app.jwt")
@Validated
public record JwtProperties(
    @NotBlank String secret,
    @Min(1) long accessTtlMinutes,
    @Min(1) long refreshTtlDays
) {}

@ConfigurationProperties(prefix = "app.oss")
@Validated
public record OssProperties(
    @NotBlank String endpoint,
    @NotBlank String bucket,
    @NotBlank String accessKey,      // 走环境变量注入,不写死在 yml
    @NotBlank String secretKey,
    @NotNull Duration signatureTtl   // 支持 5m / 300s 写法
) {}

@Configuration
@EnableConfigurationProperties({JwtProperties.class, OssProperties.class})
public class PropertiesConfig {}

// 使用:像普通 Bean 一样注入
@Service
@RequiredArgsConstructor
public class TokenService {
    private final JwtProperties props;
}

💻 多环境配置示例

# application.yml(公共配置)
spring:
  profiles:
    active: ${SPRING_PROFILE:dev}
  jackson:
    time-zone: Asia/Shanghai
app:
  jwt:
    secret: ${JWT_SECRET:dev-only-change-me}
    access-ttl-minutes: 30
    refresh-ttl-days: 7
  oss:
    endpoint: ${OSS_ENDPOINT:}
    bucket: ${OSS_BUCKET:demo}
    access-key: ${OSS_AK:}
    secret-key: ${OSS_SK:}
    signature-ttl: 5m

---
# application-prod.yml
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    url: jdbc:mysql://${DB_HOST}:3306/app?useSSL=false&serverTimezone=Asia/Shanghai
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
logging:
  level:
    root: WARN
    com.demo: INFO

💻 配置测试与环境差异切换

@SpringBootTest
@TestPropertySource(properties = {
    "app.jwt.secret=test-secret-key-0123456789",
    "app.jwt.access-ttl-minutes=5"
})
class JwtPropertiesTest {

    @Autowired JwtProperties props;

    @Test
    void should_bind() {
        assertEquals(5, props.accessTtlMinutes());
        assertNotNull(props.secret());
    }

    @Test
    void should_fail_when_missing() {   // 启动期校验缺失配置
        // 去掉 secret 后启动会直接失败,而不是运行到一半 NPE
    }
}

// 按环境切换实现(同一个接口两种实现)
@Service
@Profile("dev")
class MockSmsClient implements SmsClient { ... }

@Service
@Profile("prod")
class AliyunSmsClient implements SmsClient { ... }

🛠 动手任务

  • 把 JWT、OSS、第三方 API 的配置抽成 3 个 @ConfigurationProperties,加校验注解,并写测试断言绑定成功。
  • 用环境变量启动一次:SPRING_PROFILE=prod JWT_SECRET=xxx java -jar app.jar,确认配置被覆盖。
  • 故意漏掉一个必填配置,观察启动失败信息(而不是运行时 NPE),体会“启动期校验”的价值。

✅ 自测题与参考答案(3 题)

Q1. 命令行参数、环境变量、application.yml 谁覆盖谁?

参考答案(点击展开)

优先级从高到低:① 命令行参数(--server.port=8081、--spring.profiles.active=prod);② SPRING_APPLICATION_JSON(环境变量里塞一整段 JSON);③ ServletContext / JNDI 参数;④ 操作系统环境变量(SPRING_DATASOURCE_URL ↔ spring.datasource.url,松散绑定);⑤ application-{profile}.yml(当前激活的 profile);⑥ application.yml(默认配置);⑦ @PropertySource 注解引入;⑧ 代码里的默认值。要点:profile 专用配置会覆盖公共配置;环境变量在容器里最方便(不需要改文件);排查用 /actuator/env 可以看到每个属性来自哪个配置源。

Q2. 为什么 @ConfigurationProperties 比到处写 @Value 更好?

参考答案(点击展开)

① 类型安全与自动转换:Duration、DataSize、List、Map、嵌套对象都能绑定,@Value 只能拿字符串再自己解析;② 可校验:配合 @Validated + @NotBlank,配置缺失在启动期就失败;③ 可导航与复用:一个对象能被多个 Service 注入,改一处即可;④ IDE 支持:引入 configuration-processor 后 yml 里有自动补全与文档提示;⑤ 可测试:单测里直接 new 一个配置对象塞进去,而 @Value 需要 @TestPropertySource 或反射注入;⑥ 重构友好:配置名改了编译器能提示(配合生成的元数据)。@Value 只适合零散的一两个值。

Q3. 为什么密钥绝不能提交进 Git?已经提交了怎么办?

参考答案(点击展开)

原因:Git 历史永久保留,即使后续删除文件、改密码,密钥仍可从任意历史 commit 中还原;一旦仓库被 fork、被误设为 public、或被 CI 日志打印,密钥即泄露,可能导致云资源被刷(产生巨额账单)或数据被拖走。正确做法:① 用 .gitignore 忽略本地 .env 与 *-local.yml;② 密钥走环境变量 / 配置中心 / 云厂商的密钥管理(KMS、Secrets Manager);③ CI 里用平台提供的 Secrets,且注意不要在日志里 echo 出来;④ 接入密钥扫描(gitleaks、trufflehog)到 pre-commit 与 CI。已经提交的:立即轮换密钥(作废旧的),再用 git filter-repo / BFG 清理历史并强制推送,同时检查云账单与访问日志是否有异常调用。

⚠️ 前端容易踩的坑

  • 把生产库密码写进 application.yml 并提交(历史里永久留存)。
  • @Value 到处散落,改配置要全局搜索;且拼写错误只在启动时才炸,还没有默认值保护。
  • 本地 dev 配置被打包进生产镜像(用 profile + 环境变量注入规避)。
  • 日志里打印了整个配置对象,把密钥带进了日志文件。

Day 13 · 数据库访问:JPA / MyBatis 与事务

所属阶段:Week 2 · Spring Boot 与数据访问 | 建议时长:3-4h | 进度:第 13 / 30 天

今日导语:今天真正开始“存数据”。两条主流路线都要认识:JPA(面向对象、开发快)与 MyBatis(SQL 可控、国内主流)。更关键的是事务——它看起来只是一个注解,却有大量“写了但不生效”的陷阱,是面试与线上事故的高频区。

前端视角:对照前端:ORM ≈ Prisma / Drizzle,Repository ≈ 数据访问层封装。事务就像前端的“批量提交”——要么全成功要么全回滚;区别在于数据库事务有 ACID 保证,而 @Transactional 是基于代理实现的,有很多“看起来生效其实没生效”的坑。

🎯 今日目标

  • 跑通 MySQL + HikariCP,完成实体映射与 CRUD、分页查询
  • 理解 JPA 与 MyBatis 的取舍,能写出避免 N+1 的查询
  • 正确使用 @Transactional,知道它失效的全部常见场景

📌 两条 ORM 路线

  • JPA / Hibernate:用对象描述数据,方法名派生查询(findByEmailAndStatus),开发快、重构友好;缺点是复杂 SQL 难调优、容易踩 N+1。
    • 一级缓存(Session/持久化上下文):同一事务内 findById 两次只发一条 SQL;这也是“改了实体不 save 也会更新”的原因(脏检查后 flush)。
    • 二级缓存:跨 Session 的缓存(Ehcache/Caffeine),使用门槛高,多数团队选择直接用 Redis 做应用级缓存。
    • 脏检查机制:事务提交时 Hibernate 对比快照,有变化才发 UPDATE——所以“只 set 不改值”不会产生 SQL。
  • MyBatis-Plus:自己写 SQL,可控性强、调优直观、国内主流;缺点是改字段名要同步改 XML/注解,样板代码多。
    • Wrapper 条件构造器:QueryWrapper(字符串字段名,易错)与 LambdaQueryWrapper(方法引用,重构安全),推荐后者。
    • 分页插件:MybatisPlusInterceptor + PaginationInnerInterceptor,否则分页不生效(会全表查)。
    • SQL 注入风险:${} 直接拼接,只允许用于字段名等受控值(Day 27)。
  • 选型建议:管理后台、业务模型稳定 → JPA;复杂报表、多表联查、需要精细优化 → MyBatis。很多团队混用:简单 CRUD 用 JPA,复杂查询用 MyBatis。
    • 混合使用的边界:JPA 负责单表 CRUD 与简单关联;报表、多表聚合、复杂条件用 MyBatis 写原生 SQL(同一个项目可以共存)。
    • 团队维度:新人多、追求交付速度 → JPA;SQL 能力强、追求可控 → MyBatis。
  • 连接池 HikariCP(Spring Boot 默认):maximum-pool-size 别乱调大,经验值 ≈ CPU核数 × 2 + 磁盘数;连接不是越多越好,太多会加剧数据库锁竞争。
    • 为什么默认最大连接数是 10:数据库连接并非越多越快,超过一定数量后锁竞争与上下文切换会让吞吐下降(HikariCP 官方有实测数据)。
    • 关键参数:connection-timeout(获取连接超时,默认 30s,不要设 0)、max-lifetime(要小于数据库的 wait_timeout)、idle-timeout。
    • 监控:/actuator/metrics 里看 hikaricp.connections.active/pending,pending 持续大于 0 说明连接池不够。
  • 开启慢 SQL 日志与 spring.jpa.show-sql=false(生产用 p6spy 或数据源代理,别用 show-sql)。

📌 JPA 建模与查询

  • 实体:@Entity + @Table + @Id + @GeneratedValue(strategy = IDENTITY);@Enumerated(EnumType.STRING);@Column(nullable, unique, length)。
    • @GeneratedValue 策略:IDENTITY(MySQL 自增,插入后立即拿到 ID,但无法批量)、SEQUENCE(Oracle/PG)、UUID、雪花(分布式推荐)。
    • @Column 显式声明长度与 nullable,便于生成 DDL 校验(ddl-auto=validate 时不一致会启动报错)。
    • 字段命名:Java 的 camelCase ↔ 数据库的 snake_case,Spring Boot 默认开启物理命名策略自动转换。
  • 审计字段:@CreatedDate / @LastModifiedDate + @EntityListeners(AuditingEntityListener.class) + @EnableJpaAuditing。
    • 开启方式:启动类加 @EnableJpaAuditing,实体加 @EntityListeners(AuditingEntityListener.class)。
    • @CreatedBy / @LastModifiedBy 需要实现 AuditorAware 返回当前用户(配合 ThreadLocal)。
    • 好处:创建时间与更新时间不再依赖数据库默认值,也便于测试(可控时间)。
  • 关联:@ManyToOne / @OneToMany;默认 LAZY 的关联在序列化时会触发懒加载 → N+1。解决:@EntityGraph、JOIN FETCH、@BatchSize,或直接用 DTO 投影查询。
    • FetchType.LAZY 只对 @OneToMany/@ManyToOne 默认不同(Many 默认 EAGER,One 默认 LAZY)——显式声明更清晰。
    • 懒加载需要 Session 打开,超出事务范围访问会抛 LazyInitializationException(这也是要关 open-in-view 的原因)。
    • 双向关联的维护:只在“拥有方”设置关系,并在另一方提供 addXxx 辅助方法保持两边一致。
  • 分页:Pageable + Page;注意 Page 会额外查 count,大数据量可用 Slice 或游标。
    • Page vs Slice:Page 会查 count(多一条 SQL),Slice 只查 limit+1 条判断是否有下一页(更快)。
    • 排序稳定性:分页必须带唯一排序键(如 id),否则同分记录在不同页之间可能重复或丢失。
  • 批量写入:saveAll + 配置 rewriteBatchedStatements=true + spring.jpa.properties.hibernate.jdbc.batch_size=50。
    • JPA 批量插入三件套:hibernate.jdbc.batch_size=50、order_inserts=true、URL 加 rewriteBatchedStatements=true。
    • IDENTITY 主键策略无法批量(必须逐条拿 ID)——大批量场景改用 SEQUENCE 或雪花 ID。

📌 事务 @Transactional

  • 默认只回滚 RuntimeException 与 Error;受检异常不回滚 → 需要 @Transactional(rollbackFor = Exception.class)。
    • 为什么默认不回滚受检异常:语义上受检异常是“可预期的、调用方可能恢复”的情况,Spring 认为你可以自己决定要不要回滚。
    • 实践:统一写 @Transactional(rollbackFor = Exception.class),避免“忘记配置导致异常没回滚”。
  • 失效场景(必须背下来):① 同类内部方法调用(没走代理);② 方法不是 public;③ 异常被 catch 吞掉没抛出;④ 方法是 final/static;⑤ 数据库引擎不支持事务(MySQL 的 MyISAM);⑥ 用了错误的事务管理器或数据源未被 Spring 托管。
  • 传播行为:REQUIRED(默认,加入当前事务)、REQUIRES_NEW(挂起当前、新开一个,用于审计日志必须留存)、SUPPORTS、NOT_SUPPORTED、NEVER、MANDATORY、NESTED。
    • MANDATORY:必须在已有事务中调用,否则报错——用于强制调用方开启事务(防止“忘了加事务”)。
    • SUPPORTS:有事务就加入,没有就非事务运行——适合查询方法。
  • 事务要短:不要在事务里做 RPC、发 MQ、处理文件、加锁等待——会把数据库连接长时间占住,连接池很快耗尽。
  • 跨事务的一致性:用 @TransactionalEventListener(AFTER_COMMIT) 在提交后发消息(Day 17 会用)。

📌 Repository 模式与查询优化

  • Repository 的职责边界
    • Repository 只做数据存取与查询,不含业务规则(库存够不够是业务,不是 Repository 的判断)。
    • 返回领域对象或 PO,不要让 Controller 直接依赖它;复杂查询返回 DTO 投影(避免整表字段传输)。
    • 复杂条件用 Specification(JPA Criteria)或 QueryDSL,避免拼接字符串 SQL。
  • 批量操作的性能
    • saveAll 不等于批量插入:必须配合 hibernate.jdbc.batch_size=50 + order_inserts + URL 参数 rewriteBatchedStatements=true。
    • 超大批量(10 万+)用原生批量或 LOAD DATA / 分批提交(每 1000 条 commit 一次,避免长事务与 undo 膨胀)。
    • 删除用 deleteAllByIdInBatch 或批量 DELETE,不要循环 delete(每条一条 SQL)。
  • 分页与 count 的取舍
    • Page 会额外执行 count 查询,大表上可能比主查询还慢。
    • 优化:用 Slice(只判断有没有下一页,不查 total);或缓存 count;或改游标分页。
    • 排序字段必须有索引,否则 filesort 会拖垮分页。
  • 读写分离与多数据源(了解)
    • 思路:写走主库、读走从库;用 AbstractRoutingDataSource 或 ShardingSphere 实现。
    • 坑:主从延迟导致“刚写完读不到”,需要写后读主的策略(按业务容忍度决定)。
  • 慢 SQL 自查:开启 spring.jpa.properties.hibernate.session_factory.statement_inspector 或 p6spy 打印真实 SQL 与耗时。

📌 深入:事务传播行为实战

  • REQUIRED vs REQUIRES_NEW vs NESTED
    • REQUIRED(默认):有则加入,无则新建——同一个事务,一起提交一起回滚。
    • REQUIRES_NEW:挂起当前事务,新开一个独立事务——外层回滚不影响它。典型用途:审计日志、操作流水必须留存(哪怕主业务失败)。
    • NESTED:在当前事务里开一个保存点(savepoint),回滚只回滚到保存点,外层事务仍可继续;需要数据库支持保存点(MySQL InnoDB 支持)。
    • NOT_SUPPORTED / NEVER:以非事务方式运行 / 存在事务则报错——用于强制某些操作不被事务包裹(如大批量只读导出)。
  • REQUIRES_NEW 的经典场景:审计日志
    • 需求:下单失败要记录失败原因,日志不能因为主事务回滚而丢失。
    • 做法:把写日志的方法标 @Transactional(propagation = REQUIRES_NEW),它会在独立事务中提交。
    • 注意:它会占用一个额外的数据库连接(新事务需要新连接),连接池要留余量;且它自身回滚不会影响外层。
  • 事务与锁的关系
    • 事务里的 UPDATE/DELETE/SELECT FOR UPDATE 会持有行锁直到事务提交——所以“事务越大,锁持有越久”。
    • 高并发下的表现:大量请求等待同一行的锁 → 连接池打满 → 大面积超时。
    • 优化:缩短事务(把查询、RPC、计算移到事务外)、按固定顺序加锁、用原子 UPDATE 代替“查-判-改”。
  • 只读事务与查询优化
    • @Transactional(readOnly = true):告诉框架与数据库这是只读事务,Hibernate 会跳过脏检查(不生成快照),MySQL 路由到从库(读写分离时)。
    • 注意:readOnly 不是“性能优化万能药”,但它能避免无意义的 flush 与快照开销,并明确表达意图。
  • 事务失效的完整排查清单
    • ① 同类内部调用(没走代理)→ 拆 Bean / 自注入 / AopContext。
    • ② 方法非 public → 改成 public。
    • ③ 异常被 catch 吞掉 → 重新抛出或 setRollbackOnly。
    • ④ rollbackFor 没覆盖受检异常 → 加 rollbackFor = Exception.class。
    • ⑤ 数据库引擎不支持事务(MyISAM)→ 换 InnoDB。
    • ⑥ 方法是 final/static → 无法被代理。
    • ⑦ 多数据源用错事务管理器 → @Transactional(transactionManager = "xxx")。
    • ⑧ 自己 new 的对象 → 必须由容器创建。
  • 排查技巧:开启 spring.jpa.show-sql(开发期)或 p6spy,看是否有 BEGIN/COMMIT/ROLLBACK;也可以打印 TransactionSynchronizationManager.getCurrentTransactionName() 确认事务是否开启。

💻 JPA 实体与 Repository

@Entity
@Table(name = "t_user",
       indexes = @Index(name = "idx_email", columnList = "email"))
@Getter @Setter @NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 64)
    private String email;

    private String nickname;

    @Enumerated(EnumType.STRING)          // 永远用 STRING,别用 ORDINAL
    @Column(length = 16)
    private UserStatus status;

    @CreatedDate private LocalDateTime createdAt;
    @LastModifiedDate private LocalDateTime updatedAt;

    @Version private Long version;        // 乐观锁:并发更新保护
}

public interface UserRepo extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);

    // 复杂查询用 @Query,避免方法名过长
    @Query("select u from User u where u.status = :s order by u.createdAt desc")
    Page<User> pageByStatus(@Param("s") UserStatus s, Pageable pageable);

    // 投影查询:只查需要的字段,避免 N+1 与全字段传输
    @Query("select new com.demo.vo.UserSummary(u.id, u.email, count(o.id)) " +
           "from User u left join Order o on o.userId = u.id group by u.id, u.email")
    List<UserSummary> summary();
}

💻 事务正确用法与防超卖

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepo orderRepo;
    private final StockRepo stockRepo;

    @Transactional(rollbackFor = Exception.class)   // 显式声明,避免受检异常不回滚
    public Long createOrder(CreateOrderCmd cmd) {
        // 原子扣减:一条 SQL 完成“判断 + 扣减”,天然防超卖
        int updated = stockRepo.deduct(cmd.skuId(), cmd.qty());
        if (updated == 0) {
            throw new BizException(4001, "库存不足");   // 抛异常触发回滚
        }
        var order = orderRepo.save(Order.of(cmd));
        return order.getId();
    }
}

// 原子扣减 SQL(MyBatis / @Modifying 均可)
@Modifying
@Query("update Stock s set s.qty = s.qty - :qty " +
       "where s.skuId = :skuId and s.qty >= :qty")
int deduct(@Param("skuId") Long skuId, @Param("qty") int qty);

// 事务提交后再发事件,避免“回滚了但消息已发出”
@Component
class OrderEventPublisher {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCreated(OrderCreatedEvent e) { /* 发 MQ */ }
}

// ❌ 失效示例:同类内部调用,代理没生效
public void batch(List<Cmd> cmds) {
    cmds.forEach(this::createOrder);   // 事务不生效!应拆到另一个 Bean 或用 AopContext
}

💻 数据源与 JPA 配置

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/app?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
    username: ${DB_USER:root}
    password: ${DB_PASSWORD:}
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000        # 获取连接超时,别设 0(无限等待)
      idle-timeout: 600000
      max-lifetime: 1800000
      pool-name: app-pool
  jpa:
    open-in-view: false                # 必须关!否则懒加载在视图层触发,连接被长时间占用
    hibernate:
      ddl-auto: validate               # 生产用 validate/none,交给 Flyway 管结构
    properties:
      hibernate:
        jdbc.batch_size: 50
        order_inserts: true
        order_updates: true
    show-sql: false

💻 事务传播与失效反例(对照看)

// ✅ REQUIRES_NEW:审计日志独立提交,不受主事务回滚影响
@Service
@RequiredArgsConstructor
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(String action, boolean success, String msg) {
        auditRepo.save(new AuditLog(action, success, msg, LocalDateTime.now()));
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepo repo;
    private final AuditService audit;

    @Transactional(rollbackFor = Exception.class)
    public Long create(CreateOrderCmd cmd) {
        try {
            Order o = repo.save(Order.of(cmd));
            audit.log("createOrder", true, "ok");      // 独立事务提交
            return o.getId();
        } catch (Exception e) {
            audit.log("createOrder", false, e.getMessage());   // 主事务回滚,日志仍在
            throw e;
        }
    }

    // ❌ 失效反例 1:同类内部调用(this 绕过代理)
    public void batch(List<CreateOrderCmd> cmds) {
        cmds.forEach(this::create);        // 事务不生效!
    }

    // ✅ 修法:自注入(或用另一个 Bean 编排)
    @Lazy @Autowired private OrderService self;
    public void batchFixed(List<CreateOrderCmd> cmds) {
        cmds.forEach(self::create);        // 走代理,事务生效
    }

    // ❌ 失效反例 2:catch 后不抛出 → 事务照常提交
    @Transactional
    public void bad() {
        try { repo.save(...); throw new RuntimeException("boom"); }
        catch (RuntimeException e) { log.warn("ignored"); }   // 提交!脏数据
    }
}

🛠 动手任务

  • 建 user 与 article 两张表(一对多),实现关联查询 + 分页接口,并观察关闭 open-in-view 前后的 SQL 数量差异。
  • 写一条集成测试验证事务回滚:中间抛异常,确认前面的写操作没有落库。
  • 造一个 N+1 场景(列表 + 懒加载关联),用 @EntityGraph 或 JOIN FETCH 修复,对比 SQL 条数。

✅ 自测题与参考答案(4 题)

Q1. @Transactional 失效的三种最常见场景是什么?怎么解决?

参考答案(点击展开)

① 同类内部调用:事务是通过 AOP 代理实现的,this.method() 绕过了代理,注解不生效。解决:把方法拆到另一个 Bean、注入自己(@Lazy 自注入)、或用 AopContext.currentProxy()。② 方法非 public:Spring 的 JDK 动态代理/CGLIB 只对 public 方法生效(CGLIB 技术上可代理 protected,但 Spring 事务拦截器默认只处理 public)。③ 异常被 catch 吞掉:异常没有传播出代理边界,事务拦截器以为是正常返回,照常提交。解决:catch 后必须重新抛出,或手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。此外还有:final/static 方法、数据库引擎不支持事务、rollbackFor 未覆盖受检异常、多数据源用错事务管理器。

Q2. 乐观锁 @Version 是怎么解决并发扣库存的?和悲观锁怎么选?

参考答案(点击展开)

乐观锁:表里加 version 字段,更新时 WHERE id = ? AND version = ?,同时 SET version = version + 1;若返回影响行数为 0,说明期间被别人改过,抛异常或重试(JPA 会抛 ObjectOptimisticLockingFailureException)。它不加数据库锁,吞吐高,适合“冲突少、读多写少”的场景(如编辑资料、下单)。悲观锁:SELECT ... FOR UPDATE 直接锁住行,配合事务使用,适合“冲突频繁、必须串行”的场景(如秒杀扣库存、余额变更)。但会阻塞其他事务、占用连接,并发高时容易超时。更优的扣库存方案是“原子 UPDATE”:UPDATE stock SET qty = qty - ? WHERE sku_id = ? AND qty >= ?,一条 SQL 完成判断与扣减,无锁冲突,靠返回值判断是否成功。

Q3. 为什么必须关闭 spring.jpa.open-in-view?

参考答案(点击展开)

open-in-view 默认为 true,它会在整个 Web 请求期间保持数据库会话(Session)打开,导致 Controller 甚至 Jackson 序列化阶段还能触发懒加载查询。后果:① 一个列表接口可能在序列化时悄悄打出几十条 SQL(N+1),且这些查询发生在视图层,日志与监控很难定位;② 数据库连接被长时间占用(整个请求周期),并发一高连接池就耗尽;③ 事务边界模糊,提交时机不可控。关闭后,懒加载超出事务范围会抛 LazyInitializationException——这其实是好事,它强迫你在 Service/Repository 层用 @EntityGraph、JOIN FETCH 或 DTO 投影把数据一次性查出来,让 SQL 数量可预期、可优化。

Q4. @Transactional 同类内部调用失效,有几种解决办法?各自的代价是什么?

参考答案(点击展开)

① 拆到另一个 Bean(推荐):把需要事务的方法移到 XxxTxService,由它编排;代价是多一个类,但职责也更清晰,还顺带打破了循环依赖。② 自注入:@Lazy @Autowired private XxxService self; 然后 self.method();代价是代码里出现“注入自己”,读起来别扭,且容易被人误删。③ AopContext.currentProxy():需要 @EnableAspectJAutoProxy(exposeProxy=true),侵入性最强、与代理实现耦合,不推荐。④ 把方法放到父类或接口并由外部调用:本质还是拆类。⑤ AspectJ 织入(编译期/加载期):真正绕过代理限制,但构建复杂度大幅上升。实践中优先 ①,其次是 ②;同时建议用 ArchUnit 写一条架构测试(禁止 Service 之间形成环)从源头避免同类调用的场景。

⚠️ 前端容易踩的坑

  • 在循环里逐条 save → 用 saveAll + 批量插入配置(rewriteBatchedStatements=true + batch_size)。
  • 懒加载 + 序列化导致 N+1:一个列表接口打 100 条 SQL,压测时直接超时。
  • @Transactional 加在 Controller 上,把 HTTP 响应也拖进事务。
  • ddl-auto 用 update 上了生产:结构被 Hibernate 自动修改,与 Flyway 迁移脚本冲突,且无法追溯。

Day 14 · SQL 进阶、索引与数据库迁移

所属阶段:Week 2 · Spring Boot 与数据访问 | 建议时长:3h | 进度:第 14 / 30 天

今日导语:本周收官。今天从“会写 SQL”升级到“知道这条 SQL 为什么慢”:读懂 EXPLAIN、建立索引直觉、用 Flyway 把表结构变更版本化。这两件事决定了你的服务在数据量涨 100 倍后还能不能跑。

前端视角:对照前端:Flyway ≈ 数据库的 Git(每次变更一个版本脚本,可回溯可复现);EXPLAIN ≈ 性能面板的火焰图,告诉你这条查询到底扫了多少行、有没有走索引。

🎯 今日目标

  • 读懂 EXPLAIN,能判断索引是否生效、如何设计联合索引
  • 用 Flyway 做版本化数据库迁移,理解向后兼容的发布顺序
  • 解决深分页与慢查询,建立慢 SQL 排查流程

📌 索引设计与 EXPLAIN

  • InnoDB 用 B+ 树索引;联合索引遵循最左前缀原则——索引 (a,b,c) 能服务 a、(a,b)、(a,b,c),但无法单独服务 b 或 c。
    • 为什么是 B+ 树而不是 B 树/哈希:B+ 树高度低(3-4 层能存千万级)、叶子节点链表相连适合范围扫描、非叶节点只存 key 更省空间。
    • 聚簇索引:InnoDB 的主键索引叶子节点存整行数据,所以主键选择很重要(推荐自增或趋势递增的雪花 ID,避免页分裂)。
    • 回表:二级索引叶子存主键值,需要再查一次聚簇索引;覆盖索引(索引包含所有查询字段)可避免回表。
  • 设计顺序:等值条件列在前、范围/排序列在后;区分度高的列优先;尽量做成覆盖索引(查询字段都在索引里,避免回表)。
    • 经验法则:等值匹配列 → 排序列 → 范围列(范围列之后的列无法再用索引)。
    • 区分度计算:COUNT(DISTINCT col)/COUNT(*),越接近 1 越适合放前面。
    • 前缀索引:对很长的字符串列(如 url)可用 col(20) 建前缀索引,兼顾选择性(用 COUNT(DISTINCT LEFT(col,20)) 评估)与空间。
  • EXPLAIN 关键列:type(ALL 全表 → index → range → ref → eq_ref → const,至少要到 range)、key(实际用的索引)、rows(预估扫描行数)、Extra(Using filesort / Using temporary 是警报,Using index 是好事)。
    • type 从差到好:ALL < index < range < ref < eq_ref < const;生产出现 ALL 基本要优化。
    • Extra 里的关键提示:Using index(覆盖索引,好)、Using where(正常)、Using temporary(用了临时表,多见于 GROUP BY)、Using filesort(额外排序,多见于 ORDER BY 无索引)。
    • rows 是预估扫描行数(不是返回行数),越小越好;可配合 EXPLAIN ANALYZE 看真实执行数据。
  • 索引失效常见原因:对字段做函数运算(DATE(created_at)=...)、隐式类型转换(字符串列传数字)、前导模糊查询(LIKE '%abc')、OR 连接非索引列、违反最左前缀、优化器判断全表更快(小表或高选择性差时)。
    • 隐式类型转换:phone 是 VARCHAR 但传数字 13800138000,MySQL 会对列做 CAST → 索引失效(非常常见的坑)。
    • 字符集/排序规则不一致:关联查询的两个表字段字符集不同会导致索引失效。
    • OR 连接:一部分条件有索引一部分没有 → 整体失效,可改写成 UNION ALL 或给缺失列加索引。
    • 不等号与 NOT IN:选择性差时优化器会放弃索引(不一定是 bug,要结合实际数据量判断)。
  • 索引不是越多越好:每增加一个索引,写操作都要维护它,占用空间并降低写入吞吐。
    • 写入代价:每新增/修改/删除一行,所有相关索引都要更新 B+ 树,索引多会显著降低写吞吐。
    • 空间代价:索引可能比数据还大;内存里放不下就会变成随机磁盘 IO。
    • 维护代价:索引太多会让优化器选择困难,也增加 DDL 时间。

📌 Flyway 数据库迁移

  • 命名:V1__init.sql、V1_1__add_index.sql、V2__create_order.sql(版本号 + 双下划线 + 描述);可重复执行脚本用 R__xxx.sql。
    • 版本号支持多级:V1、V1_1、V2;双下划线后是描述(下划线会被替换成空格展示)。
    • R__ 前缀是可重复执行脚本(内容变化就重新执行),适合放视图、存储过程,不适合放 INSERT(会重复插入)。
    • 命名不要带环境信息(如 V1__prod.sql),脚本在所有环境应一致。
  • 原理:启动时对比 flyway_schema_history 表,按版本顺序执行未执行过的脚本,并校验已执行脚本的 checksum——改了已上线脚本会直接启动失败(这是保护,不是麻烦)。
    • checksum 校验:已执行脚本被修改会报 “Migration checksum mismatch”,此时只能新建版本修正,不要去改历史脚本。
    • baseline:老库首次接入用 baseline 打基线(记录当前状态,从下一个版本开始执行)。
  • 规则:脚本必须幂等与向后兼容(DDL 用 IF NOT EXISTS);已上线脚本禁止修改,需要变更就写新版本。
    • DDL 用 IF NOT EXISTS / ADD COLUMN IF NOT EXISTS(MySQL 不支持后者,可用存储过程或按版本拆分)。
    • DML 用 INSERT IGNORE / ON DUPLICATE KEY UPDATE,或先判断存在性(注意并发下仍需唯一索引兜底)。
  • 与 JPA ddl-auto 的关系:生产用 validate 或 none,结构变更全部交给 Flyway,避免两套机制打架。
    • 推荐组合:生产 validate(校验实体与表结构一致,不一致就启动失败,能提前发现问题)+ Flyway 管结构;或 none 完全交给 Flyway。
    • 绝不推荐生产用 update(结构被 Hibernate 悄悄修改,与迁移脚本冲突且不可追溯)。
  • 回滚:Flyway 社区版无自动回滚,需要自己写回滚脚本(或用 Undo 脚本的商业版);更实际的做法是“向前修复”。

📌 深分页与慢查询治理

  • offset 深分页:LIMIT 100000,20 需要先扫描并丢弃 100020 行,越往后越慢。
    • 为什么慢:MySQL 需要先读取 offset+limit 行再丢弃前 offset 行,回表成本随 offset 线性增长。
    • 业务侧的缓解:限制最大页码(如只允许前 100 页)、强制搜索条件缩小结果集。
  • 游标法(推荐):WHERE id < :lastId ORDER BY id DESC LIMIT 20,配合前端传上一页最后一条的 id/排序值,复杂度恒定。
    • 适用:信息流、无限滚动、导出;不适用:需要跳到第 N 页的后台表格(那种场景仍用 offset,但要限制页数)。
    • 游标值要有唯一性(如 id 或 (created_at, id) 组合),否则同一时刻的多条记录会漏/重。
  • 延迟关联:先用覆盖索引定位 id 子集,再 JOIN 回表取数据,减少回表量。
  • 慢查询治理流程:开启 slow_query_log(long_query_time=1s)→ performance_schema 聚合 Top SQL → EXPLAIN 分析 → 加索引或改写 SQL → 复盘回归。
  • count 优化:大表的 count(*) 很慢(InnoDB 需扫行),可用估算值、缓存计数表或干脆返回 hasMore。
    • 替代方案:近似值(EXPLAIN 的 rows、或 information_schema.TABLES 的 TABLE_ROWS)、计数表(冗余维护 total)、缓存计数。
    • 业务上很多场景其实只需要“有没有更多”(hasMore),那就用 limit+1 判断,完全不查 count。

📌 事务与锁:并发下的数据正确性

  • 隔离级别与并发问题
    • 读未提交(脏读)→ 读已提交(不可重复读)→ 可重复读(MySQL 默认,仍可能幻读)→ 串行化。
    • MySQL InnoDB 的可重复读靠 MVCC 快照读 + 间隙锁(Next-Key Lock)解决大部分幻读。
    • Spring 的 @Transactional(isolation = Isolation.READ_COMMITTED) 可覆盖,但一般跟随数据库默认即可。
  • MVCC 简要原理
    • 每行有隐藏列:trx_id(最后修改事务)与 roll_pointer(指向 undo log 版本链)。
    • 快照读(普通 SELECT)按 ReadView 判断版本可见性,所以读不加锁也不阻塞写——这是 InnoDB 高并发的关键。
    • 当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)会加锁并读最新版本。
  • 行锁、间隙锁与死锁
    • 行锁:锁住索引记录;无索引时会退化为锁全表(扫描到的记录都加锁)——所以 WHERE 条件必须有索引。
    • 间隙锁:锁住区间防止插入,是可重复读下防幻读的手段,也是死锁的常见来源。
    • 死锁:两个事务以不同顺序锁资源 → MySQL 会检测并回滚其中一个(牺牲事务持有锁少的一方);代码层要统一加锁顺序。
  • 并发扣减的三种方案对比
    • 原子 UPDATE(推荐):UPDATE ... SET qty=qty-? WHERE sku_id=? AND qty>=?,一条 SQL 完成,无锁等待,靠影响行数判断。
    • 乐观锁(@Version):冲突少时合适;冲突多会大量重试失败。
    • 悲观锁(FOR UPDATE):强一致但阻塞,高并发下连接池容易被占满,且必须严格统一加锁顺序防死锁。
  • 长事务的危害:undo log 膨胀、锁持有时间长、主从延迟;线上要监控 information_schema.innodb_trx 里运行超过 N 秒的事务。

📌 深入:索引进阶与优化器行为

  • 覆盖索引与回表
    • 回表:二级索引只存主键值,取其他列需要再查一次聚簇索引——这是一次额外的随机 IO。
    • 覆盖索引:查询的所有列都在索引里(Extra 显示 Using index),无需回表,性能可提升数倍。
    • 实践:把高频查询的“SELECT 列 + WHERE 列 + ORDER BY 列”一起考虑进联合索引;但索引过长会增加写成本,要权衡。
  • 索引下推(ICP)与 MRR
    • 索引下推(MySQL 5.6+):把 WHERE 中能用索引判断的条件“下推”到存储引擎层过滤,减少回表次数(Extra: Using index condition)。
    • MRR(Multi-Range Read):把随机回表变成顺序回表(先收集主键、排序后再查),降低 IO 成本。
    • 两者都是优化器自动决定的,我们要做的是“给它能用的索引”。
  • 优化器为什么不选我的索引
    • 原因一:统计信息不准(Cardinality 估算偏差)→ ANALYZE TABLE 更新统计。
    • 原因二:回表代价高于全表扫描(要查的行数太多)→ 这是正常的优化器选择,此时应改写查询或加覆盖索引。
    • 原因三:索引选择性太差(如 gender 只有两个值)→ 单独建索引意义不大,应放在联合索引的后面。
    • 干预手段:FORCE INDEX / USE INDEX(慎用,会失去优化器的自适应能力)、或改写 SQL。
  • ORDER BY / GROUP BY 与索引
    • 索引本身有序,若 ORDER BY 的顺序与索引一致且方向一致,就能直接顺序扫描(Extra 无 Using filesort)。
    • 混合方向(a ASC, b DESC)在 MySQL 8 支持降序索引,5.7 会出现 filesort。
    • GROUP BY 也可以用索引避免临时表(Using temporary),但要注意 GROUP BY 的列顺序同样遵循最左前缀。
  • 索引维护与线上 DDL
    • 冗余索引:有了 (a,b) 就不需要单独的 (a)——用 sys.schema_redundant_indexes 检查并清理。
    • 线上加索引:MySQL 5.6+ 支持 Online DDL(ALGORITHM=INPLACE, LOCK=NONE),大表仍建议用 gh-ost / pt-online-schema-change 避免抖动。
    • 索引不是越多越好:每多一个索引,INSERT/UPDATE/DELETE 都要维护它,写吞吐下降、空间增大。
  • 诊断工具箱:EXPLAIN(计划)、EXPLAIN ANALYZE(真实耗时)、SHOW INDEX(索引信息)、optimizer_trace(优化器决策过程)、slow log(实证)。

💻 索引设计与分页优化

-- 联合索引:等值列在前,排序列在后
CREATE INDEX idx_order_user_status_time
    ON t_order (user_id, status, created_at DESC);

-- 验证是否走索引
EXPLAIN SELECT id, amount FROM t_order
 WHERE user_id = 1001 AND status = 'PAID'
 ORDER BY created_at DESC LIMIT 20;
-- 期望:type=ref/ref_or_null,key=idx_order_user_status_time,rows 很小

-- 深分页优化一:游标法(前端传 lastId / lastScore)
SELECT id, amount FROM t_order
 WHERE user_id = 1001 AND id < :lastId
 ORDER BY id DESC LIMIT 20;

-- 深分页优化二:延迟关联(先靠索引定位 id,再回表)
SELECT o.* FROM t_order o
  JOIN (SELECT id FROM t_order WHERE status = 'PAID'
        ORDER BY id LIMIT 100000, 20) t
  ON o.id = t.id;

-- 慢查询聚合查询(找出最耗时的 SQL)
SELECT DIGEST_TEXT, COUNT_STAR,
       ROUND(AVG_TIMER_WAIT/1e9, 2) AS avg_ms
  FROM performance_schema.events_statements_summary_by_digest
 ORDER BY AVG_TIMER_WAIT DESC LIMIT 10;

💻 db/migration/V1__init.sql

CREATE TABLE t_user (
  id          BIGINT AUTO_INCREMENT PRIMARY KEY,
  email       VARCHAR(64) NOT NULL,
  nickname    VARCHAR(64) DEFAULT '',
  status      VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_email (email),
  KEY idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE t_order (
  id          BIGINT AUTO_INCREMENT PRIMARY KEY,
  user_id     BIGINT NOT NULL,
  amount      DECIMAL(18,2) NOT NULL DEFAULT 0,
  status      VARCHAR(16) NOT NULL DEFAULT 'PENDING',
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  KEY idx_user_status_time (user_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

💻 向后兼容的变更脚本(分三次上线)

-- V2__add_column.sql:第一步,只加可空列(旧代码不受影响)
ALTER TABLE t_order ADD COLUMN coupon_id BIGINT NULL;

-- (中间:部署新代码,开始双写 coupon_id;再跑数据回填脚本)

-- V3__add_index.sql:第二步,加索引(可在线 DDL 或 gh-ost)
ALTER TABLE t_order ADD INDEX idx_coupon (coupon_id);

-- (最后:确认无旧代码依赖后,再在下一个版本删除废弃列)
-- ALTER TABLE t_order DROP COLUMN old_field;

💻 Flyway 配置

spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true      # 已有库首次接入时打基线
    validate-on-migrate: true      # 校验已执行脚本的 checksum
    out-of-order: false            # 禁止乱序执行
    clean-disabled: true           # 生产禁用 clean(防误删库)

💻 索引优化实战:从慢查询到覆盖索引

-- 原始慢查询:扫描 80 万行、filesort
SELECT id, title, created_at
  FROM t_article
 WHERE status = 'PUBLISHED' AND category_id = 7
 ORDER BY created_at DESC LIMIT 20;
-- EXPLAIN: type=ALL, rows=800000, Extra=Using where; Using filesort

-- 第一步:加联合索引(等值列在前,排序列在后)
CREATE INDEX idx_art_cat_status_time
    ON t_article (category_id, status, created_at DESC);
-- EXPLAIN: type=ref, rows=1200, Extra=Using where(filesort 消失)

-- 第二步:做成覆盖索引(把查询列也放进索引,避免回表)
CREATE INDEX idx_art_cover
    ON t_article (category_id, status, created_at DESC, id, title);
-- EXPLAIN: Extra=Using index  ← 不回表

-- 第三步:深翻页改游标(原来是 LIMIT 100000,20)
SELECT id, title, created_at
  FROM t_article
 WHERE category_id = 7 AND status = 'PUBLISHED'
   AND (created_at, id) < ('2026-09-01 10:00:00', 9527)   -- 上一页最后一条
 ORDER BY created_at DESC, id DESC
 LIMIT 20;

-- 诊断辅助
SHOW INDEX FROM t_article;
ANALYZE TABLE t_article;                  -- 更新统计信息
SELECT * FROM sys.schema_redundant_indexes;   -- 找冗余索引
SELECT * FROM sys.schema_unused_indexes;      -- 找从未使用的索引

🛠 动手任务

  • 用 Flyway 建表 + 插入种子数据,重启应用确认迁移执行;故意改坏一个已执行脚本,观察 Flyway 的 checksum 校验报错。
  • 造 10 万行数据,对比「无索引 vs 有索引」「offset 深分页 vs 游标分页」的耗时,记录到笔记。
  • 开启慢查询日志,跑一遍列表接口,用 EXPLAIN 分析最慢的那条 SQL 并给出优化方案。

✅ 自测题与参考答案(4 题)

Q1. 为什么 WHERE 里对字段做函数运算会让索引失效?

参考答案(点击展开)

B+ 树索引是按“列的原始值”排序存储的。WHERE DATE(created_at) = '2026-09-29' 中,数据库需要对每一行的 created_at 先计算函数结果再比较,无法利用索引的有序性做范围定位,只能全表扫描(type=ALL)。同理的还有:YEAR(col)=2026、col+1=10、SUBSTRING(col,1,3)='abc'、对列做隐式类型转换(字符串列传数字会加 CAST)。正确写法是把函数挪到常量侧:WHERE created_at >= '2026-09-29 00:00:00' AND created_at < '2026-09-30 00:00:00',这样可以直接走范围扫描。若确实需要按日期查询且无法改写,可建函数索引(MySQL 8 支持在生成列上建索引)。

Q2. 迁移脚本为什么要“向后兼容”地分多次上线?

参考答案(点击展开)

因为代码部署与数据库变更不是原子的:总会存在“新代码 + 旧结构”或“旧代码 + 新结构”的中间态,且回滚时二者顺序也可能颠倒。向后兼容的做法能容忍这些中间态:① 加列:先加可空列(旧代码不知道它也不报错)→ 部署双写的新代码 → 回填历史数据 → 再切读 → 最后(下个版本)删旧列/加非空约束。② 改列名:先加新列并双写 → 切读 → 删旧列,而不是直接 RENAME(旧代码会立刻崩)。③ 删列:先停止读写该列的代码并上线 → 观察 → 再删列。这样每一步都可独立回滚,任何时刻系统都能正常工作。

Q3. offset 分页为什么慢?有哪些替代方案?

参考答案(点击展开)

LIMIT 100000,20 需要数据库先扫描出前 100020 行、丢弃前 100000 行,再返回 20 行——扫描量与 offset 成正比,翻页越深越慢,且这些扫描行还要回表取数据。替代方案:① 游标法(最推荐):WHERE id < lastId ORDER BY id DESC LIMIT 20,用上一页最后一条的排序键继续,复杂度恒定;适合信息流、无限滚动。② 延迟关联:先用覆盖索引只查 id 子集,再 JOIN 回表,减少回表数据量。③ 限制深翻页:只允许翻前 N 页(如 100 页),配合搜索/筛选缩小结果集。④ 预计算:把结果集快照到缓存/临时表,分页从快照读。⑤ 业务上改用“加载更多”或按时间/分类分片。

Q4. 明明加了索引,查询还是很慢,可能有哪些原因?怎么排查?

参考答案(点击展开)

按顺序排查:① 索引真的用上了吗——EXPLAIN 看 key 与 type,若为 ALL 说明没走。常见原因:违反最左前缀、对列做了函数/运算、隐式类型转换(字符串列传数字)、前导通配 LIKE '%x'、OR 中有无索引列、字符集不一致。② 用上了但扫描行数仍然多——rows 很大说明选择性差或条件太宽;看是否需要更精准的联合索引。③ 大量回表——Extra 没有 Using index,考虑覆盖索引。④ 排序/分组产生 filesort 或 temporary——让 ORDER BY/GROUP BY 与索引顺序一致。⑤ 优化器选错索引——统计信息过期(ANALYZE TABLE),或优化器判断全表更快(小表)。⑥ 分页太深——改游标。⑦ 锁等待——SHOW PROCESSLIST / innodb_trx 看是否被别的慢事务阻塞,此时 SQL 本身不慢,是等锁慢。⑧ 数据量确实太大——考虑分区、归档、分库分表。排查证据链:slow log 找 SQL → EXPLAIN 看计划 → EXPLAIN ANALYZE 看真实耗时分布 → 改索引或 SQL → 复测对比。

⚠️ 前端容易踩的坑

  • 直接在生产库手改表结构,导致各环境结构漂移、无法复现。
  • 给低区分度字段(如 gender、is_deleted)单独建索引,选择性太差基本没用。
  • SELECT * 带上大字段(TEXT/BLOB),网络与内存双重浪费,还可能触发临时表落盘。
  • 生产开启 spring.flyway.clean-disabled=false,误执行 clean 直接清库。