前端工程师的 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(),出现 或 $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 直接清库。