ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot+Hibernate+MySQL最小闭环与排错指南

Spring Boot+Hibernate+MySQL最小闭环与排错指南

简介:这份 Spring Boot 学习项目面向刚接触 Java Web 与持久层框架的开发者,围绕 Spring Boot 整合 Hibernate 连接 MySQL 展开,重点演示实体映射、简单插入与查询等基础流程。压缩包共 155 个文件,约 34.25MB,以 jar 依赖包为主体,同时包含 Java 源码与编译类、properties 配置、JSP 页面、XML 描述以及一份 SQL 数据库脚本,并带有版本控制相关元数据,便于直接导入 IDE 对照源码理解项目结构。已有 1829 人学习或下载。项目把依赖与建表脚本一并收拢,适合用作课程练习、毕业设计原型或框架入门模板;读者可从中了解 Spring Boot 与 Hibernate 的配置衔接、实体与表字段对应关系,以及基础增查逻辑的组织方式。源码规模不大,配置项集中,适合按实体、映射、数据访问与页面展示几部分拆解学习,也便于在此基础上改造表结构与查询条件,借助现成脚本快速复现实验环境。

1. 三件套跑通的最小闭环:springboot+hibernate+mysql 例子到底该包含什么

有人把 springboot hibernate mysql 这个组合的原理想得很透,但一上来就卡在启动日志里,代码一行没改,换台机器就跑不起来。问题往往不在注解写得好不好,而在依赖坐标、连接串参数、建表语句这三处没有形成闭环。一个合格的“简单例子”,至少要能证明四件事:容器能起来、连接池能拿到连接、实体能映射到真实表、接口能完成一次写入并查回来。这四步缺任何一步,后面加业务都是空中楼阁。

这篇面向三类人:刚接触 JPA 想搞清@Entity与表之间到底怎么对应的新手;手上有个老项目要从 JDBC 迁到 Hibernate 的从业者;以及只想找一份能直接跑通、不用改十处的参照骨架的人。下面按依赖配置、实体映射、接口验证、故障排查、日志调试的顺序推进,每一步都给出可直接抄的配置和能复现的命令。

2. 依赖与连接配置:pom 三个坐标和 yml 里的九个参数

动手之前先明确一件事:这份骨架的价值不在于代码多,而在于每一行配置都有理由。新手最常见的做法是从别处拷一份 pom 和 yml,然后逐个试错把报错消掉。这种做法在本地能跑,一旦换环境就全部失效,因为不知道哪个参数在起作用,也就不知道哪个参数可以删。

2.1 三个坐标的分工:容器、ORM、驱动

一个能跑的 springboot + hibernate + mysql 例子,核心依赖只有三个,其余都是可选的辅助。分工必须分清,否则很容易陷入版本冲突的坑。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <!-- 示例版本,实际请换成本地仓库里已经下载过的 3.x,不要空着 --> <version>3.2.5</version> </parent> <dependencies> <!-- 1. Web 层:内嵌 Tomcat + Jackson,负责把对象序列化成 JSON --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 2. ORM 层:传递引入 hibernate-core 与 spring-data-jpa --> <!-- 重点:不要再单独声明 hibernate-core,否则版本会和 Boot 管的那个打架 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- 3. 驱动:8.x 之后坐标从 mysql:mysql-connector-java 换成了 com.mysql:mysql-connector-j --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>

参数说明上,spring-boot-starter-parent决定了整条依赖链的版本,只要它定了,JPA、Hibernate、Jackson 的版本都不用写。spring-boot-starter-data-jpa是传递依赖,它会把hibernate-core、spring-orm、spring-data-jpa一起拉进来,所以手动再加一次hibernate-core是这个组合里最容易犯的错:Maven 会按“最近优先”选一个版本,结果可能是 Hibernate 6 的 jar 配上 Boot 2 的自动配置类,启动时报NoSuchMethodError或者ClassNotFoundException,而且堆栈指向框架内部,看不出是自己加的依赖惹的祸。

驱动的作用域必须是runtime。编译期代码里不该出现com.mysql.cj.jdbc.Driver这个类名,因为 Spring Boot 会根据 URL 自动推断驱动;写成runtime还有个好处,换数据库时只改配置不改代码。如果非要在 yml 里显式写driver-class-name,也可以,但要写com.mysql.cj.jdbc.Driver,老教程里的com.mysql.jdbc.Driver在 8.x 驱动里已经废弃,启动时会打一行警告,不影响运行但会误导排查。

判断依赖是否干净,用一条命令就够:

# 只看 hibernate 和 mysql 驱动最终解析到哪个版本,确认没有被手动依赖顶掉 mvn -q dependency:tree -Dincludes=org.hibernate.orm:*,com.mysql:*

输出里hibernate-core应该只有一行,mysql-connector-j也应该只有一行。如果出现omitted for conflict或者同一坐标出现两个版本,说明有人在 pom 里手动加过版本号,回到上一步删掉即可。我一般会在给别人的骨架里保留这条命令的注释,因为排查依赖冲突比排查逻辑错误省时间得多。

2.2 application.yml 逐行:连接串里每个参数为什么在这

配置文件是这套组合的出问题最集中的地方。下面这份 yml 是能跑通的最小集合,每个参数后面都有具体理由。

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true username: demo password: demo123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1700000 jpa: database-platform: org.hibernate.dialect.MySQLDialect hibernate: ddl-auto: update show-sql: true open-in-view: false properties: hibernate: format_sql: true jdbc: batch_size: 30 logging: level: org.hibernate.SQL: debug
参数作用不写会怎样
useUnicode+characterEncoding指定客户端字符集中文写入变成???,且不报错
serverTimezone告诉驱动服务端时区8.x 驱动直接抛时区无法识别异常
useSSL关闭不必要的 TLS 握手本地连接多一次握手开销,日志有警告
allowPublicKeyRetrieval允许取服务端公钥8.x 默认认证插件下报公钥检索不允许
maximum-pool-size连接池上限默认 10,够用但要看数据库max_connections
connection-timeout拿连接的等待时间默认 30 秒,出问题时整个请求挂死
ddl-auto是否自动维护表结构生产误开update会悄悄改动字段
open-in-view是否把 Session 延到视图渲染开着容易掩盖懒加载问题,并在连接归还上出错
batch_size批量写入时攒多少条发一次循环 insert 变成 N 次网络往返

这里需要单独说明open-in-view。它的默认值是 true,作用是让 Hibernate 的 Session 一直存活到响应序列化完成,好处是 Controller 层拿到懒加载集合不会立刻报错,坏处是数据库连接在整段序列化期间都被占着,高并发下连接池很快耗尽,而且懒加载异常被推迟到难以定位的位置才暴露。这份骨架把它设为 false,意味着所有需要的数据必须在 Service 的事务内取完,这样延迟加载问题会在开发期暴露,而不是在线上暴露。

ddl-auto的取值需要单独列清楚,因为它是新手最容易误用的一个开关。

取值行为适用场景
none不做任何结构操作生产环境,配合手工迁移脚本
validate只校验,不匹配则启动失败预发、联调,用来抓实体与表不一致
update缺表建表、缺列加列,不删不改本地开发、演示例子
create每次启动先删表再建表单元测试、每次都要干净数据
create-drop启动建、关闭删内存库的集成测试

一个常见做法是:本地用update快速迭代,提交前改成validate跑一次,确认实体和建表脚本完全对齐,再进测试环境。这样既享受了自动建表的便利,也不会把“靠 Hibernate 猜表结构”的习惯带进生产。

2.3 建库建表 SQL 与账号授权

即使开着update,我也建议把建表语句显式写出来。原因很实际:Hibernate 自动生成的表,字段类型、索引名、字符集都由方言决定,换一个版本可能就不一样,而显式脚本是可评审、可版本管理的。

-- 字符集一定用 utf8mb4,MySQL 的 utf8 是 3 字节,存不下四字节字符 CREATE DATABASE IF NOT EXISTS demo_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE demo_db; CREATE TABLE IF NOT EXISTS t_product ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, created_at DATETIME NULL, PRIMARY KEY (id), KEY idx_product_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 应用账号:只给这个库的增删改查和建表权限,不要图省事给 ALL CREATE USER IF NOT EXISTS 'demo'@'127.0.0.1' IDENTIFIED BY 'demo123'; GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON demo_db.* TO 'demo'@'127.0.0.1'; FLUSH PRIVILEGES;

几个参数值得说明:DECIMAL(10,2)而不是DOUBLE,金额用浮点会在累加时出现精度偏差,这个在商品价格场景里是硬性要求;DATETIME和TIMESTAMP的区别在于前者不做时区转换、后者会,配合serverTimezone时如果不一致,查出来的时间会整体偏移八小时,属于典型的“看着对但就是差一点”的问题;ENGINE=InnoDB是事务和行锁的前提,MyISAM不支持事务,@Transactional在上面完全不起作用。

账号的主机部分写127.0.0.1而不是%,是为了让连接来源固定。如果写成%,MySQL 会按“最具体匹配”选账号,本机上同时存在demo@localhost和demo@%时,实际用的可能不是你以为的那个,密码对不上就报Access denied。这类问题在排查时最费时间,因为配置看上去完全正确。

3. 实体映射与仓储层:把 t_product 表变成可查询的对象

配置通了之后,接下来是把表变成 Java 对象。这一层的核心不是注解多,而是知道每个注解在生成 SQL 时到底起了什么作用。搞不清这一点,后面所有查询问题都会变成猜测。

3.1 @Entity 到表结构:字段类型与主键策略

package com.example.demo.domain; import jakarta.persistence.*; import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @Table(name = "t_product", indexes = @Index(name = "idx_product_name", columnList = "name")) public class Product { // MySQL 自增主键必须用 IDENTITY,不能用 AUTO @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "name", nullable = false, length = 64) private String name; @Column(name = "price", nullable = false, precision = 10, scale = 2) private BigDecimal price; @Column(name = "stock", nullable = false) private Integer stock = 0; // updatable = false:插入时写入,后续 update 语句不带这一列 @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @PrePersist void onCreate() { this.createdAt = LocalDateTime.now(); if (this.stock == null) { this.stock = 0; } } // getter / setter 必须补齐,Jackson 序列化和 JPA 属性访问都依赖它们 }

jakarta.persistence这个包名是关键。Spring Boot 3 起,Java EE 迁移到 Jakarta EE,包名从javax.persistence变成了jakarta.persistence。网上大量教程还是javax,直接拷过来会报package javax.persistence does not exist。如果项目确实还停在 Boot 2.x,那就得整篇换成javax,两者不能混用,混用的表现是编译通过但启动时实体扫描不到,@Entity形同虚设。

主键策略这一项单独强调:MySQL 场景下必须用GenerationType.IDENTITY。用AUTO时,Hibernate 6 会默认选择基于序列的策略,而在 MySQL 上没有原生序列,它会退化成一张额外的序列表来模拟,结果就是每次插入多一条select ... for update和一次update,压测时这部分开销相当可观。用TABLE同理。用IDENTITY则是真正依赖AUTO_INCREMENT,插入后由驱动回填主键,开销最小。

索引的写法也值得注意。@Index只有在ddl-auto为create或update且表由 Hibernate 创建时才生效;如果你的表是手工 SQL 建的,这里的注解只是文档性质,真正的索引得写进建表语句。我一般两处都写,保持它们一致,因为读实体的人不一定会去看 SQL 文件。

命名策略容易被忽略。如果不写@Table,Boot 默认的CamelCaseToUnderscoresNamingStrategy会把Product映射成product,把createdAt映射成created_at。这个策略对纯小写表名友好,但一旦表名带前缀(比如t_product)就必须显式写@Table。反过来,如果数据库里是tProduct这种驼峰表名,就得在 yml 里改物理命名策略,否则怎么都对不上。

3.2 Repository:方法名派生、JPQL、批量更新三种写法

package com.example.demo.repository; import com.example.demo.domain.Product; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.math.BigDecimal; import java.util.List; public interface ProductRepository extends JpaRepository<Product, Long> { // 写法一:方法名派生,findBy + 属性 + 关键字 + OrderBy // 生成:where name like ? order by id desc List<Product> findByNameContainingOrderByIdDesc(String keyword); // 写法二:JPQL,写实体名和属性名,不是表名和列名 @Query("select p from Product p where p.price between :min and :max order by p.price") List<Product> findByPriceRange(@Param("min") BigDecimal min, @Param("max") BigDecimal max); // 写法三:分页,返回值是 Page 时会自动执行一条 count 查询 Page<Product> findByStockGreaterThan(Integer stock, Pageable pageable); // 写法四:批量更新,必须加 @Modifying,且调用方要有事务 // 返回值是受影响行数,可以用它做乐观并发判断 @Modifying(clearAutomatically = true) @Query("update Product p set p.stock = p.stock - :n where p.id = :id and p.stock >= :n") int deductStock(@Param("id") Long id, @Param("n") Integer n); }

四种写法的取舍标准很明确。方法名派生适合条件少于三个的固定查询,超过三个方法名会长到没人愿意读,而且And连接的语义是“全部满足”,做不了or的分组,这时候必须换 JPQL。JPQL 的优势是面向对象、可读、能跨数据库,缺点是拼不了动态条件。真正需要按前端传参动态拼条件时,用Specification或者Criteria,这是另一套东西,简单例子里不必引入。

@Modifying这条要重点说。不加它,执行 update 语句会直接抛异常,因为 Spring Data 默认认为所有@Query都是查询。加了之后还必须有事务,否则会报TransactionRequiredException,因为更新操作需要显式提交。clearAutomatically = true的作用是更新后清空一级缓存,否则同一个事务里先前查出来的实体还在缓存中,再查会拿到旧值,表现为“我明明改了但查出来还是老数据”。

批量更新还有一个隐含限制:它绕过 Hibernate 的实体生命周期,@PreUpdate、@LastModifiedDate这类回调都不会触发,审计字段不会自动更新。这一点在给已有项目加审计逻辑时经常出问题,写的时候就要判断清楚:如果这张表依赖回调维护字段,就别用批量更新,老老实实findById再set。

3.3 Service 与事务:边界画在哪一层

package com.example.demo.service; import com.example.demo.domain.Product; import com.example.demo.repository.ProductRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @Service public class ProductService { private final ProductRepository repo; // 构造器注入,不要用字段注入,单测时能直接 new public ProductService(ProductRepository repo) { this.repo = repo; } // 只读事务:Hibernate 会跳过脏检查,读多写少的场景有明显收益 @Transactional(readOnly = true) public Page<Product> page(int page, int size) { return repo.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"))); } @Transactional(readOnly = true) public Optional<Product> findById(Long id) { return repo.findById(id); } // rollbackFor = Exception.class:默认只对 RuntimeException 回滚 @Transactional(rollbackFor = Exception.class) public Product create(Product p) { return repo.save(p); } @Transactional(rollbackFor = Exception.class) public void deduct(Long id, Integer n) { int affected = repo.deductStock(id, n); if (affected == 0) { // 库存不足或商品不存在,抛异常触发回滚 throw new IllegalStateException("库存不足或商品不存在: " + id); } } }

事务边界放在 Service 而不是 Repository,理由是:一个业务动作可能涉及多次仓储调用,只有放在 Service 才能保证它们在同一事务内。放在 Repository 上,每次调用都是独立事务,中途失败前面的写入不会回滚。

rollbackFor = Exception.class这一条是血泪经验。@Transactional默认只在遇到RuntimeException和Error时回滚,受检异常不回滚。如果业务代码里抛了自定义的受检异常,事务会照常提交,数据半截写入,而且日志上看不出任何异常,只有对数据的时候才发现对不上。

还有一个 AOP 层面的问题:同类内部方法互相调用时,事务注解不生效。比如create里直接调this.deduct(...),deduct上的事务注解会被绕过,因为它没经过 Spring 生成的代理对象。解决办法是把被调方法挪到另一个 Bean,或者注入自身代理。

4. 接口闭环实测:4 个 CRUD 接口与 curl 验证清单

实体和仓储都有了,最后一步是把链路打通并验证。这一步不要靠“看着像对”,要用具体的请求和具体的返回体去对。一个能跑通的例子,验证过程本身就应该能被别人照着重放。

4.1 Controller 四个接口的最小实现

package com.example.demo.web; import com.example.demo.domain.Product; import com.example.demo.service.ProductService; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/products") public class ProductController { private final ProductService service; public ProductController(ProductService service) { this.service = service; } // 分页查询:page 从 0 开始,这是最容易和前端对不上的地方 @GetMapping public Page<Product> list(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { return service.page(page, size); } @GetMapping("/{id}") public ResponseEntity<Product> get(@PathVariable Long id) { return service.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @PostMapping public Product create(@RequestBody Product p) { return service.create(p); } @PutMapping("/{id}") public Product update(@PathVariable Long id, @RequestBody Product p) { return service.update(id, p); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { service.delete(id); return ResponseEntity.noContent().build(); } }

@RestController等价于@Controller加@ResponseBody,返回值直接由 Jackson 序列化成 JSON,不走视图解析。ResponseEntity用来控制状态码:查不到返回 404,删除成功返回 204,这两个语义比一律 200 更利于前端判断。

page的默认值写 0 是因为 Spring Data 的页码从 0 开始,而前端传参习惯从 1 开始。这个差异必须在接口层说清楚,否则第一页的数据永远被跳过。我一般会在文档里显式写明,或者干脆在 Controller 里接收 1 基页码再减一,避免每次联调都有人问“为什么少了一条”。

@RequestBody Product p直接接收实体对象,在简单例子里可以接受,但在正式项目里不推荐。实体会带上id、createdAt这类由服务端维护的字段,客户端传什么就写什么,容易造成误覆盖。规范做法是定义一个ProductCreateRequest只包含可写字段,这里为了保持例子简洁暂时省略,但从一开始就应该知道这是个待改进点。

4.2 curl 逐条验证与返回体检查

# 1. 新增:POST 一条数据,观察返回体里 id 是否被回填 curl -s -X POST http://127.0.0.1:8080/api/products \ -H 'Content-Type: application/json' \ -d '{"name":"测试商品A","price":19.90,"stock":100}' # 2. 单查:确认 id、created_at 都正确写入 curl -s http://127.0.0.1:8080/api/products/1 # 3. 分页:确认 totalElements 和 content 的长度 curl -s 'http://127.0.0.1:8080/api/products?page=0&size=5' # 4. 更新:全量覆盖,注意没传的字段会被置为 null curl -s -X PUT http://127.0.0.1:8080/api/products/1 \ -H 'Content-Type: application/json' \ -d '{"name":"测试商品A-改","price":29.90,"stock":80}' # 5. 删除:应返回 204,无响应体 curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:8080/api/products/1

验证的重点不是“有没有返回”,而是几个具体字段。新增后返回体里的id必须有值且与数据库里一致,这是IDENTITY策略生效的证据;createdAt必须有值,这是@PrePersist生效的证据;分页返回里的totalElements、totalPages、number、size四个字段要能对上,number应为 0 而不是 1。

更新这一步要特别注意“全量覆盖”语义。PUT用的是save,Hibernate 对游离态对象执行的是 merge,没传的字段会变成 null 写入数据库。如果只想改价格,应该用PATCH加局部更新,或者在 Service 里先查出来再逐个字段赋值。这个坑在实际项目里非常常见,表现为“改了个名字,库存清零了”。

4.3 分页、排序与条件查询的落地细节

分页接口在真实使用中通常还要带排序和条件。排序参数可以直接通过Sort.by构造,也可以交给 Spring 从请求参数解析:

# 按价格升序、id 降序,多字段排序直接用逗号分隔 curl -s 'http://127.0.0.1:8080/api/products?page=0&size=10&sort=price,asc&sort=id,desc'

要让这种写法生效,Controller 的参数需要改成接收Pageable:

@GetMapping public Page<Product> list(Pageable pageable) { return repo.findAll(pageable); }

Spring 会自动把page、size、sort三个参数装配成Pageable。默认size是 20,sort不传则按主键。生产环境一定要给size加上限,否则前端传个size=100000就能把内存打满,做法是配置spring.data.web.pageable.max-page-size,或者在 Controller 里手动截断。

带条件的查询则要考虑 count 查询的开销。Page<T>会自动生成一条select count(*),条件复杂时这条 count 可能比主查询还慢。解决办法是给@Query显式指定countQuery,或者在数据量大的场景改用Slice<T>(只查下一条是否存在,不做总数统计)。这个取舍在简单例子里体现不出来,但一旦表过百万行就会立刻显现。

5. 避坑排查:启动、连接、映射三类翻车现场各拆一遍

这套组合的报错有个共同特点:堆栈指向框架内部,真正的错误原因被埋在几十行Caused by后面。下面按出现阶段拆开,每条都给出可直接对照的处置方式。

5.1 启动阶段:数据源与依赖版本

现象一:启动直接失败,日志里是Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured。

原因:三种可能。一是 yml 里spring.datasource的缩进错了,YAML 对空格敏感,用 Tab 或者多缩进一层都会让这段配置变成另一个节点的子属性;二是spring-boot-starter-data-jpa引入了但驱动没引入,Spring 找不到可用的连接实现;三是项目里同时存在application.properties和application.yml,properties 优先级更高且内容不全,覆盖掉了 yml 里的配置。

解决:先跑mvn dependency:tree -Dincludes=com.mysql:*确认驱动在不在。再检查target/classes下有几个配置文件,有多个就删掉不用的那个。缩进问题用 IDE 的 YAML 插件看层级,spring下的datasource必须是四个空格缩进。

现象二:编译通过,启动时报java.lang.NoClassDefFoundError: javax/persistence/Entity或NoSuchMethodError: org.hibernate...。

原因:包名或版本错配。Boot 3 用jakarta.persistence,代码里写javax就编译不过;但如果是旧代码编译产物残留,或者 pom 里手动加了hibernate-core的低版本,就会在运行时才炸。

解决:全局搜索javax.persistence替换成jakarta.persistence;mvn dependency:tree确认hibernate-core只有一行;执行mvn clean清掉旧的编译产物再启动,很多时候“改了没生效”就是 target 目录没清。

5.2 连接阶段:认证、时区与网络

现象三:java.sql.SQLException: Public Key Retrieval is not allowed。

原因:MySQL 8 默认认证插件是caching_sha2_password,客户端首次连接需要向服务端索取公钥来完成密码加密,驱动默认禁止这个行为。

解决:在连接串加allowPublicKeyRetrieval=true。如果同时开了useSSL=true,这个参数就不必要了,因为 TLS 通道已经能保证安全。本地开发用前者更方便,线上则应该配好证书走后者。

现象四:The server time zone value '中国标准时间' is unrecognized or represents more than one time zone。

原因:驱动拿到的服务端时区是一个本地化名称,无法映射成ZoneId。Windows 上的 MySQL 尤其容易出这个问题。

解决:连接串里显式写serverTimezone=Asia/Shanghai。注意这个参数只影响驱动对时间的解释,不改变数据库存储的值。如果同时用TIMESTAMP字段,还要确认 MySQL 服务端的time_zone变量,两边不一致时读写会各偏一次,这是“存进去是九点、查出来是十七点”的根因。

现象五:Access denied for user 'demo'@'localhost',但密码明明是对的。

原因:MySQL 的账号是“用户名 + 主机”二元组。demo@127.0.0.1和demo@localhost是两个不同账号,驱动解析localhost时可能走 socket 也可能走 TCP,匹配到的账号不同。

解决:select user, host from mysql.user where user='demo';看清楚有哪几条记录。应用侧统一用127.0.0.1而不是localhost,并在建账号时按同样的主机写授权,两边对齐就不会有歧义。

5.3 映射与写入阶段:表名、主键与事务

现象六:Table 'demo_db.t_product' doesn't exist,但表确实建了。

原因:ddl-auto设成了none或validate,而实体映射出来的表名和实际表名不一致。常见差异是下划线命名策略没生效、@Table拼写错误、或者 MySQL 在 Linux 上默认lower_case_table_names=0区分大小写,本地 Windows 上不区分所以看不出来。

解决:打开show-sql,看启动时校验语句里用的表名到底是什么,与show tables的结果逐个字符比对。跨平台部署时统一用小写加下划线命名,是最省事的约定。

现象七:批量更新接口返回成功,但数据库里数据没变。

原因:三种可能。@Modifying漏了,或者加了但调用方没有事务;方法上加了@Transactional但被同类内部调用绕过代理;clearAutomatically没开,读到的是缓存里的旧值。

解决:先在show-sql输出里确认 update 语句到底有没有发出。发出了但数据没变,查事务提交日志;没发出,查@Modifying和代理调用链。判断是否走了代理有个简单办法:在方法里打一行日志,如果事务注解生效,日志会在TransactionInterceptor之后输出。

6. 把 Hibernate 的 SQL 日志变成调试黑匣子:三个开关与一张观察表

排查到最后你会发现,绝大多数“玄学问题”都能从实际发出的 SQL 里看出答案。问题在于默认配置下你看不到它,或者看到了也是一堆挤在一起的单行文本。我习惯在骨架里就把三个开关配好,出问题时不用改代码,改配置重启即可。

spring: jpa: show-sql: true # 由 Spring 打印,格式固定,信息量最小 properties: hibernate: format_sql: true # 把 SQL 格式化成多行,参数一目了然 generate_statistics: true # 输出每次请求的查询次数、耗时、缓存命中 logging: level: org.hibernate.SQL: debug # 打印 SQL 语句本身 org.hibernate.orm.jdbc.bind: trace # Boot 3.x:打印绑定参数 # Boot 2.x 用这行替换上一行:org.hibernate.type.descriptor.sql.BasicBinder: trace
开关能看到什么典型用途
show-sql单行 SQL快速确认语句生成了没
format_sql多行带缩进的 SQL看 where 条件、join 顺序
org.hibernate.SQL与show-sql类似但走日志框架配合级别控制,生产可临时打开
绑定参数(trace)每个?实际填入的值排查“条件明明传了却查不到”
generate_statistics单次请求的 SQL 条数抓 N+1 查询

最后一项最有用。开启后控制台会输出类似Session Metrics {nanoTime, sessionOpenCount, ... , statementCount: 5, ...}的汇总行,重点看statementCount和prepareStatementCount。一个列表接口返回 10 条数据,如果这里显示 11 次查询,那就是典型的 N+1,说明关联对象是一个一个懒加载出来的,解决办法是改用join fetch或者@EntityGraph一次性抓取。

有一点必须提醒:generate_statistics有实实在在的性能开销,它会在每个 JDBC 调用前后记录时间戳,压测时会拉低吞吐。所以它的定位是“本地和预发环境的诊断工具”,不能常驻生产。我一般的做法是把它写进application-dev.yml,生产 profile 里压根不出现这个键。

从那次为了找一个“列表页偶发超时”的问题、盯着 300 多行挤成一坨的 SQL 翻了两个小时之后,我每次新建这类骨架都强制先把上面这套日志配置加上,再写第一行业务代码。看起来是多花了五分钟,实际省下的是几个小时的黑盒猜测。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进