上一节,我们把一句简单的问候扩展成了完整的任务 API。Controller 接住 HTTP 请求,Service 处理业务规则,Repository 暂时用内存集合保存任务。只要应用还在运行,创建、查询、修改、删除都能正常工作。从客户端的角度看,它已经很像一个后端服务了。
问题会在你重启应用时突然暴露出来:刚才创建的任务全没了。不是删除接口误删了数据,而是内存集合和 Java 进程一起结束了。下一次启动得到的是一个全新的集合。对于练习 CRUD,这很轻便;对于要长期保存的任务,它显然不够。
这一次我们不推翻上一节的设计。/api/tasks 的路径不变,请求和响应 JSON 不变,Controller 也不需要知道数据去了哪里。我们只把存储一侧从内存 Repository 换成 Spring Data JPA 和 H2。这个改动正好能检验分层是否真的有用:如果客户端契约和业务规则都不用跟着数据库重写,那几层代码就不是形式主义。
这节还要把几件看似“自动发生”的事拆开:为什么只写一个 Repository 接口就能查库,@Entity 怎样变成表结构,@Transactional 为什么能控制提交和回滚,以及为什么实体类不应该直接当 API 请求体。等这些机制对上号,JPA 就不再是一组只能照抄的注解。
上一节的请求大致沿着这条路径运行:
HTTP 请求
↓
TaskController
↓
TaskService
↓
内存 TaskRepository
↓
ConcurrentHashMap本节完成后,前两层保持原样,变化集中在最后一段:
HTTP 请求
↓
TaskController
↓
TaskService(事务边界)
↓
TaskRepository(Spring Data 生成代理 Bean)
↓
EntityManager / Hibernate
↓
DataSource / JDBC
↓
H2 数据库
这里有四个名字很容易搅在一起。JPA 是 Java 持久化的规范,它定义实体、持久化上下文和 EntityManager 等概念;Hibernate 是本项目实际使用的 JPA 实现,负责把实体操作翻译成 SQL;Spring Data JPA 在它们之上提供 Repository 抽象,减少重复的数据访问代码;H2 才是最后真正执行 SQL、保存表和记录的数据库。
你可以把它们理解成不同层次的协议与执行者,但别停在比喻上。TaskService 调用的对象是 Spring Data 创建的 Repository 代理,代理再调用 JPA 基础实现;JPA 操作由 Hibernate落实;Hibernate 通过 JDBC 从 DataSource 取得连接,把 SQL 发给 H2。任何一层报错,堆栈里都会出现不同的类名。知道这条调用链,排查问题时就不会只盯着自己的 Service。
本节使用 H2 的内存模式,是为了让你不用先安装数据库服务器就能观察 JPA 全流程。它适合学习和快速测试,不代表生产环境也应该把数据放在内存里。应用进程结束后,本节的 H2 数据仍会消失;我们解决的是“请求之间可以通过数据库读回数据”,还没有解决“进程结束后仍永久保存”。
内存仓库里,Map<Long, Task> 保存的是 Java 对象引用。Service 查到对象后直接改字段,Map 中的同一个对象也随之变化;编号来自我们写的计数器;应用只要加一把合适的锁,就能在很小的范围内维持并发安全。它没有表、列、SQL、连接和提交这几个概念。
关系数据库的语义更严格。任务要被拆成一行,各字段要落入确定的列;主键生成要由数据库与 JPA 协调;字符串长度、非空值和状态范围可以受到约束;一次请求拿到的是某个事务视角下的数据。修改对象不会在任意时刻立刻变成持久数据,Hibernate 会在刷新或提交时判断应该发出哪些 SQL,数据库再决定这些 SQL 是否满足约束。
查询顺序也是一个典型差别。遍历 Map 得到什么顺序,常常只是当前实现碰巧如此;数据库如果没有 order by,也不承诺返回顺序。后面加入分页时,我们会明确指定 createdAt desc。如果不先建立这个意识,数据少时列表看似稳定,数据一多或执行计划变化,页面就会突然跳动。
还有对象身份问题。同一个持久化上下文内两次按同一主键查任务,Hibernate 会尽量维持“同一数据库行对应同一托管对象”的身份;跨事务后得到的则可能是另一个 Java 实例。判断两个实体是否代表同一条业务记录,要围绕标识设计,不能把内存引用相等当成数据库语义。
所以这次改造虽然只替换 Repository 实现,Service 已经进入了一套新的运行模型。理解事务与实体状态,比记住 save() 方法名更重要。
打开项目根目录的 pom.xml。Web 与 Validation 依赖已经在上一节存在,现在把下面三项放进 <dependencies>:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-h2console</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId
本课程统一使用 Spring Boot 4.1.0。这个版本把一些能力拆成了更细的模块,因此 H2 Web 控制台单独使用 spring-boot-h2console。如果你从旧教程里看到只加 h2 就能打开控制台,不要急着怀疑自己的配置;先确认教程对应的 Spring Boot 版本。类似地,旧代码里的 javax.persistence.* 也不要复制,本项目统一使用 jakarta.persistence.*。
spring-boot-starter-data-jpa 会带入 Spring Data JPA、Hibernate、Spring JDBC、事务支持和连接池。我们没有给这些库逐个写版本号,因为 Spring Boot 父 POM 已经管理了彼此兼容的版本。h2 标成 runtime,表示业务代码编译时不直接依赖 H2 的具体类,运行时才需要它的 JDBC 驱动。
添加依赖后执行:
./mvnw dependency:tree -Dincludes=org.springframework.data,org.hibernate.orm,com.h2database输出里会出现三条关键分支:
com.welearn:taskhub:jar:0.0.1-SNAPSHOT
+- org.springframework.boot:spring-boot-starter-data-jpa:jar:...
| +- org.hibernate.orm:hibernate-core:jar:...
| \- org.springframework.data:spring-data-jpa:jar:...
\- com.h2database:h2:jar:...:runtime这段输出的重点不是背版本号,而是确认职责都到位了。只有 H2 没有 JPA starter,Spring 不会凭空得到 Repository 基础设施;只有 JPA starter 没有 JDBC 驱动,应用也找不到可连接的数据库。
依赖进入类路径后,Spring Boot 会根据当前条件选择一组自动配置。发现 JDBC API、连接池和 H2 驱动后,它可以从 spring.datasource.* 创建 DataSource;发现 JPA 与 Hibernate 后,它会建立 EntityManagerFactory;发现事务基础设施后,它会提供适用于 JPA 的事务管理器;Spring Data 再扫描 Repository 接口并创建代理。
这些对象最后都在同一个 ApplicationContext 中成为 Bean。我们的代码不用写 new HikariDataSource()、不用手工拼 EntityManagerFactory,也不用逐个登记 Repository。省略的是稳定而重复的装配代码,不是运行步骤本身。
自动配置也不是“只要名字写对就肯定成功”。它依赖明确条件:驱动类必须能加载,URL 必须合法,实体必须可扫描,接口的领域类型必须是受管理实体。条件不满足时,某个 Bean 不会创建,或者创建过程中直接失败。遇到启动错误时,沿着 DataSource → EntityManagerFactory → Repository → Service 的依赖顺序阅读最底层原因,通常比只看最后一行“应用启动失败”有效。
这也是为什么本课程不要求你一开始手写所有配置类。先让 Boot 的默认装配工作,再通过日志和 Bean 依赖理解它做了什么。等默认值真的不合适时,再覆盖最小的一处,而不是为了显得可控,把框架已经验证过的装配重新手抄一遍。
在 src/main/resources/application.yml 中加入数据库配置:
spring:
application:
name: taskhub
datasource:
url: jdbc:h2:mem:taskhub;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password: ""
jpa:
hibernate:
ddl-auto: create-drop
open-in-view: false
properties:
hibernate:
format_sql: true
h2:
这份配置看起来长,逐项拆开其实很明确。
jdbc:h2:mem:taskhub 创建一个名为 taskhub 的内存数据库。名字很重要:应用连接池和 H2 控制台必须使用同一个 URL,才能看到同一座数据库。只写 jdbc:h2:mem: 会创建连接私有的匿名数据库,很容易出现“应用明明插入了数据,控制台却什么也看不到”的错觉。
MODE=PostgreSQL 让 H2 在部分语法和行为上靠近 PostgreSQL,能较早发现一些非常表面的兼容问题。它并不会把 H2 变成 PostgreSQL。两者在数据类型、保留字、锁、隔离级别、索引计划、日期函数和并发行为上仍有差异,后面仍要用真实 PostgreSQL 做集成测试。
DB_CLOSE_DELAY=-1 表示最后一个连接归还后,不立刻销毁这座内存数据库。应用运行期间,连接池可以关闭旧连接、再创建新连接,数据仍在。它无法让数据跨越 JVM 重启。DB_CLOSE_ON_EXIT=FALSE 则把关闭时机交给 Spring Boot 管理,避免 H2 自己的退出钩子和应用关闭流程互相抢先。
用户名 sa 和空密码只适用于本地教学。Spring Boot 能从 JDBC URL 推断 H2 驱动,也能根据当前持久化实现选择合适的方言,所以我们没有重复配置 driver-class-name 和 database-platform。能由确定信息推断出来的配置少写一份,就少一个填错后互相矛盾的地方。
ddl-auto=create-drop 会在本次启动时根据实体创建表,在正常关闭时删除表。它让我们改完实体就能重新观察 schema,很适合这一节。它也明确告诉你:这里的库是可丢弃的练习环境,不是生产数据。
open-in-view=false 关闭了 Web 请求阶段延长持久化上下文的默认做法。数据库访问应该在 Service 的事务里完成,返回 Controller 前转换成 DTO。这样如果代码在响应序列化时才偷偷触发懒加载,会尽早报错,而不是悄悄占用连接、发出你没预料到的查询。
最后两项日志分别显示 SQL 和绑定参数。只开 org.hibernate.SQL 时,你会看到 ? 占位符;把 org.hibernate.orm.jdbc.bind 调到 TRACE,才能看到每个参数的类型和值。参数日志可能包含隐私或敏感业务数据,生产环境不能长期这样开。我们只在本地观察机制时使用。
H2 控制台能直接执行 SQL,也能查看整座数据库,只应在开发环境开启。不要把 spring.h2.console.enabled=true 带到可被外网访问的生产配置中。下一节会把开发配置与生产配置拆开。
上一节的 Task 只是存放字段的普通 Java 对象。现在 Hibernate 需要知道它对应哪张表、哪个字段是主键、枚举怎样落库。修改 Task.java:
package com.welearn.taskhub.task;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
@Entity
@Table(name = "tasks")
public
@Entity 是运行时元数据。应用启动时,Hibernate 扫描并建立实体映射,知道 Task 需要被持久化。@Table(name = "tasks") 明确指定表名,避免把 Java 类名、数据库命名策略和我们的 SQL 认知混在一起。
每个实体都必须有标识。@Id 说明 id 是实体主键,@GeneratedValue(strategy = GenerationType.IDENTITY) 说明值由数据库的 identity 列产生。创建对象时 id 保持 null;执行插入后,Hibernate 把数据库生成的值写回当前对象。因此 POST 响应能马上得到编号,而不是由 Service 自己维护一个 AtomicLong。
@Enumerated(EnumType.STRING) 把 TODO、IN_PROGRESS、DONE 按名字保存。若使用序号,枚举当前可能被保存成 0、1、2。以后在中间插入一个状态,旧数据的含义就可能错位。字符串会多占少量空间,却更容易读,也更能承受枚举顺序变化。
@Version 不是业务版本号。Hibernate 更新一条任务时会把当前 version 放进更新条件,并在成功后递增。两个请求同时读到版本 0,先提交的请求把它改成 1,后提交的请求再用版本 0 更新时就匹配不到记录,从而暴露覆盖写冲突。它不会锁住整张表;后面讲并发更新时,我们还会继续使用这个字段。
那个 protected Task() 看起来没有被业务代码调用,却不能随便删。JPA 需要一个 public 或 protected 的无参构造器来创建实体实例。把它设为 protected,既满足持久化框架,也表达“业务代码不应该先造一个字段全空的任务”。
@Column 不是请求校验的替代品。DTO 上的 Validation 注解负责在请求进入业务逻辑前给出清楚的 400 错误;列长度和非空约束负责守住最终数据边界。两层约束应该一致,但报错时机和职责不同。
JPA 里的实体不是“加过注解的 DTO”,它有生命周期。刚通过 new Task(...) 创建、还没有交给持久化上下文时,它处于新建状态。调用 Repository 的 save() 后,新任务被纳入持久化上下文,成为托管对象;数据库生成 id 后,这个 id 会回填到对象。
查询出来的任务同样是托管对象。只要当前事务和持久化上下文仍然有效,Hibernate 就会跟踪它的变化。事务结束、上下文关闭后,对象会变成分离状态。它仍然是一个普通 Java 对象,getter 也能读已经加载的字段,但之后的 setter 不再自动产生更新。删除操作则会把托管实体标记为待删除,刷新时执行 DELETE。
这四种状态解释了很多初学时显得随机的现象:为什么新对象必须先 save();为什么查出后直接改能更新;为什么把实体缓存到一个全局变量,过一会儿再改却没有 SQL;为什么把一个带旧 id 的对象直接传给 save(),行为比插入新对象复杂。框架没有随机决定,它是在根据实体状态选择持久化动作。
持久化上下文还带有一级缓存。同一事务里已经按主键加载过任务,再次查找时,Hibernate 可以先检查当前上下文,不必每次都重新构造对象。这个缓存的范围通常很短,只服务于当前工作单元,不等于应用级缓存,也不能解决跨请求性能问题。后面看到一次查询“怎么没有第二条 SELECT”时,先想到当前持久化上下文,而不是误以为 Spring 自动给整个系统加了缓存。
createdAt 设置 updatable = false,表达创建时间在插入后不参与普通更新。它是映射提示和 SQL 生成边界,不是审计系统。真实项目还可能使用实体回调、审计字段或数据库默认值;本节先把时间由构造器明确生成,保证数据流容易跟踪。
删除上一节用于保存 Map 和生成自增编号的内存实现,把 TaskRepository.java 改成:
package com.welearn.taskhub.task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
}第一次看到这段代码,最合理的疑问就是:接口连方法体都没有,findAll()、findById()、save() 和 delete() 到底是谁执行的?
答案不是 Java 自动实现了接口,也不是编译器替你生成了一个肉眼看不见的 .java 文件。应用上下文启动时,Spring Data 的 Repository 扫描器会在主应用包下找到这个接口,读取它继承的领域类型 Task 和主键类型 Long,再由 Repository 工厂创建运行时代理。这个代理背后连接到包含通用 CRUD 逻辑的 JPA 实现,并持有 EntityManager。最后,代理对象以 TaskRepository 类型注册成 Spring Bean。
于是 TaskService 构造器需要一个 TaskRepository 时,IoC 容器注入的不是接口本身,而是那个运行时代理对象。调用过程可以写成:
repository.findById(1L)
↓
Repository 代理拦截方法调用
↓
通用 JPA Repository 实现
↓
EntityManager 查找 Task
↓
Hibernate 生成并执行 SELECT
↓
JDBC 把结果交给 H2
这与前面讲过的依赖注入正好接上。Spring 容器不只会把我们亲手写的 @Service 类实例化成 Bean,也允许框架根据接口元数据创建代理 Bean。后面 @Transactional 也会用代理在方法调用前后加入事务逻辑。Spring 中很多“加一个注解就生效”的能力,真实参与者往往都是容器、元数据和代理。
这里不用额外给接口写 @Repository。继承 Spring Data Repository 类型并被扫描到,已经足以让它成为仓库 Bean。省掉注解不等于没有扫描,也不等于没有 Bean。
JpaRepository<Task, Long> 的两个泛型参数不是装饰。第一个告诉基础实现要操作哪种实体,第二个告诉它主键是什么类型。Spring Data 因而能为 findById(Long id)、existsById(Long id)、deleteById(Long id) 和 findAll() 等通用方法准备正确的实体元数据。
以后我们在接口中声明 findByStatus(TaskStatus status, Pageable pageable) 时,代理还会解析方法名,把 Status 对应到实体属性并创建查询。通用 CRUD 来自基础 Repository 实现,自定义的派生查询来自方法签名解析,两者最终都会借助 EntityManager 执行。复杂条件不适合塞进越来越长的方法名时,再使用 JPQL、Specification 或专门的查询实现。第六节会专门处理这部分。
save() 也不能简单翻译成“执行一条 INSERT”。Spring Data 会根据实体是否被判断为新对象,选择持久化或合并路径。Task 的 id 在插入前为 null,很容易判断是新对象。对于携带 id 的分离对象,合并操作可能返回另一个受托管实例,所以稳妥写法是使用 save() 的返回值。更新业务中我们更倾向于先在事务内查询受托管实体,再修改允许变化的字段,这同时避免客户端用一个残缺对象覆盖数据库中的其他列。
Repository 层减少的是重复的 JDBC 样板,不会自动替你决定业务语义。比如“删除不存在的任务应该返回 204 还是 404”“标题是否允许重复”“完成任务后是否还能修改截止日期”,这些仍然属于 Service。不要因为 Repository 已经提供 deleteById(),就让 Controller 绕过 Service 直接调用它。
如果启动时报“找不到 TaskRepository Bean”,按机制排查:主应用类是否位于 com.welearn.taskhub 根包,Repository 是否在它的子包,JPA starter 是否真的加载成功。如果报“Not a managed type”,说明 Repository Bean 可能已经找到,问题转移到了实体映射:检查 Task 的 @Entity、jakarta.persistence 导入和实体所在包。
Controller 的职责仍然是解释 HTTP,DTO 的职责仍然是约束输入和稳定输出。真正需要改造的是 Service 对 Repository 的调用。下面保留上一节的 CRUD 形状,列表分页和复杂查询留到后面的数据处理课程:
package com.welearn.taskhub.task;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public

构造器注入几乎没变,只是内存实现换成了 TaskRepository 接口。Controller 继续依赖同一个 TaskService,客户端也继续发送同样的 JSON。这正是上一节提前分层换来的收益。
@Transactional 的生效方式也值得拆开。容器为 TaskService 创建事务代理;Controller 调用公开的 Service 方法时,先进入代理。代理向事务管理器申请事务,把当前线程上的数据库操作纳入这次事务,然后调用真正的 Service。方法正常返回就提交;默认情况下,方法抛出运行时异常就回滚。注解本身没有提交数据库的能力,完成这些动作的是代理和事务管理器。
事务应该围绕一次业务操作,而不是机械地围绕每一句 SQL。更新任务包含“查出任务、修改字段、写回”三步,它们共同组成一个工作单元。将边界放在 Service,未来即使一次操作需要访问两个 Repository,它们也能参加同一个事务。若只依赖每个 Repository 方法各自的事务,findById() 结束后上下文就断开,后续业务步骤也无法作为整体提交或回滚。
读取方法写 readOnly = true,是在告诉事务基础设施这是读取工作。它主要是一项优化提示,不是数据库权限系统,也不能替代代码审查。写方法使用普通 @Transactional。
update() 中最反直觉的一行,可能是根本没有 repository.save(task)。这不是漏写。
在事务内通过 Repository 查询到的 Task 处于“托管”状态。持久化上下文保存了它被读出时的状态。我们调用 setter 改字段后,Hibernate 在事务提交前执行脏检查,对比前后差异并生成 UPDATE。这就是 JPA 的脏检查。
你当然可以再次调用 save(),在这个例子里通常也能工作,但它会掩盖实体状态的真正机制。对于新对象,save() 让它进入持久化上下文并执行插入;对于事务中已经托管的对象,修改后等待提交即可。
如果把查询放在一个事务里,把修改拖到事务之外,实体就可能已经分离,修改不会自动写回。也正因为如此,我们在事务结束前就把实体转换为 TaskResponse,同时关闭 open-in-view,不让 JSON 序列化阶段继续碰实体会话。
事务代理只能拦截经过代理的调用。常见情况下,同一个类里用 this.someTransactionalMethod() 调另一个事务方法,不会再次经过代理,内部方法上的新事务设置可能不生效。最稳妥的做法是把清晰的业务用例放在可由外部调用的 Service 公共方法上,不要用私有方法上的 @Transactional 期待代理拦截。
把 update() 展开成时间线,可以看到事务不只是最后调用一次 commit。
Controller 调用注入的 TaskService。这个引用指向 Spring 创建的代理,代理读取 update 方法上的事务元数据,取得数据库连接并开始事务,然后才进入真正的业务方法。
findTask 调用 Repository 代理。Repository 使用当前事务关联的 EntityManager 执行按 id 查询,并把结果放入当前持久化上下文。若没有记录,业务异常会沿调用栈抛出。
Service 修改标题、描述、状态、优先级和截止日期。此刻主要发生的是 Java 对象状态变化,Hibernate 记录的原始快照与当前字段已经不同,不必每个 setter 都立刻发送一条 SQL。
若第二步找不到任务,或第四步违反数据库约束,代理会走回滚路径。默认回滚规则主要针对运行时异常和错误;受检异常是否回滚要根据事务规则显式决定。入门代码里不要为了“统一处理”把所有异常抓住后只打印日志再正常返回,那会让事务代理误以为方法成功,从而提交本应回滚的修改。
事务持续时间也需要克制。不要在事务里等待用户输入、调用一个可能卡几十秒的远程接口,或者做大量与数据库无关的文件处理。事务越长,连接占用越久,锁和冲突窗口也越大。Service 边界要覆盖完整数据库工作单元,同时避免把整个 Web 请求里所有事情都包进去。
@Transactional(readOnly = true) 会把读取意图传给事务和持久化实现。Hibernate 可以据此减少某些脏检查开销,数据库驱动也可能获得只读提示。但它不是 Java 编译器的限制,不能保证你一写 setter 就立刻报错,不同数据库对只读事务的执行限制也不完全一样。
因此,我们仍然把所有修改集中在普通写事务中,并用测试确认结果。把 readOnly 当成明确意图和优化机会,而不是安全权限。真正的写权限要由数据库账户、应用授权和业务规则共同约束。
接入 JPA 之后,继续让 Controller 直接收发 Task 会显得很省事:少写几个 record,查询结果也能直接转成 JSON。但这份省事会把数据库模型、实体生命周期和 HTTP 契约绑成一个东西。
创建任务时,客户端不该提交 id、status、createdAt 和 version。如果请求体直接绑定实体,这些服务端字段很容易被误接收。以后 Task 加上成员、标签等关联,实体还可能含有懒加载代理或双向关系;直接序列化既可能额外查库,也可能产生循环 JSON。数据库列改名或内部字段增加时,也不应该无意中改变公开 API。
TaskHub 因此保留三种边界对象:
public record CreateTaskRequest(
String title,
String description,
int priority,
LocalDate dueDate) {
}
public record UpdateTaskRequest(
String title,
String description,
TaskStatus status,
int priority,
LocalDate dueDate) {
}
public record TaskResponse(
Long id,
String title,
String description,
TaskStatus status,
int priority,
LocalDate dueDate,

上一节已经给请求 DTO 加过 @NotBlank、@Size、@Min 和 @Max,这里没有重复展开。关键是数据流向:HTTP JSON 先变成 Request DTO,Service 根据它创建或修改实体,事务内把实体变成 Response DTO,Controller 最后把 Response DTO 写回 JSON。
分开之后,JPA 实体可以专心描述持久化状态,Request DTO 可以专心描述哪些字段允许客户端输入,Response DTO 可以专心维持公开输出。多几个小文件不是为了摆出“三层架构”的样子,而是为了让变化各自停在合理边界。
以 title 为例,客户端提交的是 JSON 字符串。消息转换器先把它放进 CreateTaskRequest.title,Validation 检查不能为空且不超过 120 个字符。Service 再执行 trim(),把整理后的值交给实体构造器。Hibernate 根据实体映射把它绑定到 tasks.title,数据库列的非空与长度约束做最后防守。返回时,Service 从实体读取标题,放入 TaskResponse,消息转换器才把它写成 JSON。
同一个字段经过多层,并不等于每层都在重复做相同工作。请求校验给调用者可理解的错误;Service 负责“首尾空格是否属于标题”这种业务决定;实体和数据库维护持久化边界;响应 DTO 决定公开格式。若将来数据库把标题拆成主标题和副标题,外部 API 仍可以暂时维持一个 title;只需要调整映射,不必逼所有客户端同时升级。
再看 version。它存在于实体和响应,却不存在于创建请求,因为客户端不负责生成初始版本。未来做并发控制时,更新请求可以显式带上期望版本,但那应该是一次经过设计的 API 契约变化,而不是因为实体刚好有这个字段,就自动允许客户端覆盖它。
DTO 还让错误位置更清楚。请求连格式都不合法时,在 Controller 边界返回 400;请求格式合法但任务不存在时,由 Service 抛出领域异常并映射为 404;数据库最终约束被违反时,则说明上层规则可能漏了,需要记录和修复。所有错误都塞进实体 setter,会让这几类边界混在一起。
现在启动应用:
./mvnw spring-boot:runHibernate 读取实体映射后,会输出一段结构等价的建表 SQL。不同 Hibernate 小版本可能调整换行和列顺序,字段含义应该一致:
create table tasks (
priority integer not null,
due_date date,
created_at timestamp(6) with time zone not null,
id bigint generated by default as identity,
version bigint not null,
status varchar(32) not null,
title varchar(120)
这段 DDL 让实体注解落到了具体数据库对象上:id 是 identity 主键,Java 的 LocalDate 对应日期列,Instant 对应带时区语义的时间戳,EnumType.STRING 变成可读的状态字符串,@Version 得到一个非空数值列。

阅读 DDL 时,不要只找“有没有 tasks”。逐项对照实体:Java 原始类型 int 和 long 不能为 null,所以 priority 与 version 是非空列;Long id 在插入前允许为空,但数据库生成后成为主键;description 没有 nullable=false,因此允许没有描述;状态检查约束只接受当前三个名字。映射和预期不一致时,应在这一刻发现,而不是等接口收到奇怪数据后再猜。
日志中的 DDL 是观察工具,迁移脚本才会成为生产 schema 的来源。两者应当描述同一个结果。以后实体新增字段却忘记写迁移,生产使用 validate 启动时就会失败,这种失败是保护:它阻止代码在不匹配的表结构上继续运行。
如果日志里完全没有建表语句,先检查 ddl-auto 和 org.hibernate.SQL 的日志级别;如果有建表却访问时报表不存在,检查运行请求的应用是否连接到同一个 JDBC URL。不要一看到“table not found”就反复给实体加注解,先判断问题发生在扫描、建表还是连接三个阶段中的哪一个。
应用保持运行,访问 /h2-console,填写:
JDBC URL: jdbc:h2:mem:taskhub;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
User Name: sa
Password:连接后执行:
select id, title, status, priority, version
from tasks
order by id;刚启动且还没有创建任务时,结果是 0 行。它证明表已经存在,不代表插入失败。接下来我们通过 API 写一行,再回到这里重查。
发送与上一节相同的创建请求:
curl -i -X POST http://localhost:8080/api/tasks \
-H 'Content-Type: application/json' \
-d '{
"title": "验证部署流程",
"description": "从构建、启动到健康检查",
"priority": 4,
"dueDate": "2026-08-20"
}'响应仍然是 201,公开字段也没有因为存储实现变化而改变。本次运行已经有两条初始化任务,所以数据库分配的编号是 3;完全空的库通常会从 1 开始。业务代码不应预先猜测下一个编号:
HTTP/1.1 201 Created
Content-Type: application/json
Location: http://127.0.0.1:8080/api/tasks/3
{
"id": 3,
"title": "验证部署流程",
"description": "从构建、启动到健康检查",
"status": "TODO",
"priority": 4,
"dueDate": "2026-08-20",
"createdAt": "2026-08-17T08:46:22.702569Z",
编号现在由数据库生成,status 来自实体默认值,createdAt 在构造实体时由服务端确定,version 则由 JPA 管理。日志中能看到参数化插入:
DEBUG org.hibernate.SQL :
insert into tasks
(created_at, description, due_date, priority, status, title, version, id)
values
(?, ?, ?, ?, ?, ?, ?, default)
TRACE org.hibernate.orm.jdbc.bind : binding parameter (1:TIMESTAMP_UTC) <- [2026-08-17T08:46:22.702569Z]
TRACE org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [从构建、启动到健康检查]
TRACE org.hibernate.orm.jdbc.bind : binding parameter (3:DATE) <- [2026-08-20]
TRACE org.hibernate.orm.jdbc.bind : binding parameter (4:INTEGER) <- [4]
TRACE org.hibernate.orm.jdbc.bind : binding parameter (5:VARCHAR) <- [TODO]
TRACE org.hibernate.orm.jdbc.bind : binding parameter (6:VARCHAR) <- [验证部署流程]
TRACE org.hibernate.orm.jdbc.bind : binding parameter (7:BIGINT) <- [0]SQL 用 ? 传参,不是把标题直接拼进字符串。这让 JDBC 能按类型绑定数据,也避免把用户输入当成 SQL 结构。使用 JPA 不意味着再也不用关心 SQL;恰恰相反,打开日志观察“对象操作最后变成了什么 SQL”,是学习和排错最快的办法。
看 SQL 日志时可以固定问四个问题。第一,执行次数是否符合预期,一次列表请求有没有意外出现几十条查询;第二,where 条件是否带上了真正需要的 id、状态或版本;第三,参数类型是否正确,例如日期有没有被当成普通字符串;第四,事务结束前是否出现了本不该有的更新。这个习惯会在以后遇到 N+1 查询、分页 count 和乐观锁冲突时继续有用。
不要把完整参数日志当成普通应用日志保存。标题和描述可能含用户输入,未来任务里还可能出现内部项目名。学习时短暂开启 TRACE,问题定位后就关闭;生产排错优先记录查询耗时、模板和关联标识,对敏感值做脱敏。
再查刚才的任务:
curl -i http://localhost:8080/api/tasks/3HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 3,
"title": "验证部署流程",
"description": "从构建、启动到健康检查",
"status": "TODO",
"priority": 4,
"dueDate": "2026-08-20",
"createdAt": "2026-08-17T08:46:22.702569Z",
"version": 0
对应查询会近似为:
select
t.id,
t.created_at,
t.description,
t.due_date,
t.priority,
t.status,
t.title,
t.version
from tasks t
where t.id = ?此时回到 H2 控制台执行上一段 select,就能看到一行任务。浏览器、Controller、Service、Repository、Hibernate、JDBC、H2 的整条链路已经对上了。
现在停止应用再重新启动,编号 3 的任务会消失,因为 mem 数据库随进程销毁;无论前半段使用 create-drop,还是最终由 Flyway 重建 schema,都没有改变内存数据的进程生命周期。如果你只是想在本地跨重启保留数据,可以把 URL 改成 H2 文件模式;不过本课程会把生产持久化目标放在 PostgreSQL,而不是逐渐把 H2 当成正式数据库。
向编号 3 发送完整更新:
curl -i -X PUT http://localhost:8080/api/tasks/3 \
-H 'Content-Type: application/json' \
-d '{
"title": "验证部署流程",
"description": "构建、启动、探针均已检查",
"status": "DONE",
"priority": 4,
"dueDate": "2026-08-20"
}'事务开始后,Service 先按 id 查询实体,再修改托管对象。提交前的 SQL 会带上版本条件:
update tasks
set
description = ?,
due_date = ?,
priority = ?,
status = ?,
title = ?,
version = ?
where
id = ?
and version = ?这次运行的直接响应仍显示 version: 0:
{
"id": 3,
"title": "验证部署流程",
"description": "构建、启动、探针均已检查",
"status": "DONE",
"priority": 4,
"dueDate": "2026-08-20",
"createdAt": "2026-08-17T08:46:22.702569Z",
"version": 0
}这不是版本列失效,而是响应 DTO 的构造时机早于事务代理的最终 flush。Service 方法先执行 TaskResponse.from(task) 并返回,代理随后提交事务,Hibernate 才执行带版本条件的 UPDATE,并把数据库中的 version 更新为 1。紧接着再次 GET /api/tasks/3,读到的 version 就是 1。
如果接口契约要求 PUT 响应必须立即携带提交后的版本,可以在映射响应前显式 flush,或者重新设计返回时机。不要为了让示例“看起来像已经加一”,手工对 version 做 +1;版本值属于 JPA 并发控制,必须与实际写入结果一致。
如果业务代码在修改一半时抛出运行时异常,事务代理会回滚,这次更新不会只写入一部分字段。对单行更新来说,数据库本来也会保证单条 SQL 的原子性;事务的真正价值会在一个业务用例包含多次查询和写入时更明显。后面我们会用失败测试观察“第一步成功、第二步失败”是否留下半成品。
ddl-auto 常见值可以这样理解:

网上很多入门文章会把 update 写成“一劳永逸”的生产配置。它确实方便:改个字段,Hibernate 尝试改表。但它没有一份供团队审查的版本脚本,也没有清楚记录每个环境从哪个 schema 版本升级而来。遇到重命名列、拆分字段、大表建索引或数据回填时,自动猜测更不够用。应用启动也不是适合偷偷执行不可控 DDL 的时机。
生产项目应让 Flyway 或 Liquibase 这类迁移工具单独负责 schema,把每次变化保存为版本化脚本。为了让课程项目从这一节开始就保留正确的演进方式,我们在看懂 Hibernate 建表结果后,把最终配置切换到 Flyway。
先在 pom.xml 加入迁移支持。H2 由 starter 直接支持;项目后面连接 PostgreSQL,因此也提前放入对应数据库模块:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<scope>runtime</scope>
</dependency>第一份迁移放在:
src/main/resources/db/migration/V1__create_tasks.sqlcreate table tasks (
id bigint generated by default as identity primary key,
title varchar(120) not null,
description varchar(2000),
status varchar(32) not null,
priority integer not null,
due_date date,
created_at timestamp with time zone not null,
迁移工具会按版本顺序执行尚未应用的脚本,并在 schema 历史表中记录版本与校验信息。一旦 V1 已进入共享环境,就不回头静默改它;下一次变化新增 V2__...sql。这样代码版本和数据库变化能一起评审,也能明确判断某个环境究竟执行到了哪里。
然后把本节最初用于观察的 create-drop 改为 validate,让 Flyway 成为唯一建表者:
spring:
jpa:
hibernate:
ddl-auto: validate
flyway:
enabled: true重新启动时,输出会先表明 Flyway 把空 schema 迁移到 V1,再由 Hibernate 验证实体映射:
Schema history table "PUBLIC"."flyway_schema_history" does not exist yet
Migrating schema "PUBLIC" to version "1 - create tasks"
Successfully applied 1 migration to schema "PUBLIC", now at version v1
Initialized JPA EntityManagerFactory for persistence unit 'default'同一份 H2 内存库每次进程启动仍是空的,所以 Flyway 会重新创建历史表并执行 V1;外部 PostgreSQL 已经保存 schema 历史时,只会执行尚未应用的新版本。现在课程项目的最终状态就是“Flyway 管结构,Hibernate 做映射与校验”,而 create-drop 只保留为前半段理解 DDL 生成的临时观察步骤。
不要同时让 Hibernate、schema.sql 和迁移工具争夺建表权。选择一种 schema 管理机制,并让其他机制退回校验或关闭。多个初始化机制偶尔“都能跑”并不代表顺序稳定,真正出问题时你很难判断表到底是谁改的。
假设下一版 TaskHub 要给任务增加 assignee 字段。团队先确定字段语义、是否允许为空、旧数据怎样填充,再新增 V2__add_task_assignee.sql。开发库从 V1 升到 V2,测试环境走同样脚本;应用实体随后加入对应映射,并让 ddl-auto=validate 确认最终结构匹配。
如果列必须非空,直接在一张已有百万行数据的表上加非空列可能失败或长时间锁表。更稳妥的迁移可以分几步:先加允许为空的列,部署能同时理解新旧数据的应用,分批回填旧记录,确认没有空值后再加非空约束。迁移工具负责按顺序执行,不负责替你设计这个上线过程。
已经应用到共享环境的 V2 不应被悄悄修改。校验和的变化会暴露“历史脚本被改写”,这正是需要解决的问题,而不是要绕过的麻烦。若 V2 有缺陷,新增 V3 修正。数据库历史因此像代码提交一样可以追踪,每个环境也能回答“我执行到了哪个版本”。
回滚同样不能想当然。某些 DDL 可以事务化,某些数据库操作不能;删除列即使语法能回滚,丢掉的数据也不会凭空回来。生产迁移要有备份、兼容发布顺序和恢复方案,不能把“工具有 migrate 命令”误解成所有变更都能一键无损撤销。
下一节会把开发与生产连接信息拆成 Profile:开发仍使用可随时重建的 H2,生产改连外部 PostgreSQL。两边都由同一组版本化迁移描述 schema,Hibernate 保持 validate,从而避免“测试环境一套表、生产环境靠启动时猜另一套表”。
H2 的价值很实际:启动快,进程内就能运行,支持 JDBC、事务和大量标准 SQL。你可以用它确认实体是否被扫描、Repository 代理是否创建、事务是否提交、基本 CRUD 是否生成合理 SQL。这些反馈对刚接触 JPA 的人非常有用。
但 H2 通过了,不等于 PostgreSQL 一定通过。兼容模式只是模拟部分行为,无法复制生产数据库的完整类型系统、执行计划、锁竞争和扩展语法。一个查询可能在 H2 上忽略大小写差异,却在生产库表现不同;某个列名可能在一边合法,在另一边是保留字;并发事务和索引使用更不可能靠一个小型内存数据库完全代替。
比较稳妥的测试分工是:快速的 Repository 测试可以使用 H2;涉及生产方言、迁移脚本、约束、锁和复杂查询的集成测试,用 Testcontainers 启动与生产一致的 PostgreSQL。我们会在测试课程中把这条防线补上。
你还要记住一个容易说错的点:H2 内存模式并没有提供跨重启持久化。我们本节标题所说的“存进数据库”,指数据不再由 Java Map 直接保管,而是进入关系表并接受事务管理。真正跨进程保存,需要 H2 文件模式或外部数据库;TaskHub 的生产方向是后者。
知道差异后,有人会走向另一个极端:既然 H2 不能完全代表生产库,那就完全不该使用。这也不准确。测试工具的价值取决于它负责哪一类反馈。H2 能在很短时间内启动,适合反复验证映射、基础 CRUD、事务提交和简单 Repository 行为;开发者每改一小处就能得到反馈,成本很低。
真实 PostgreSQL 测试更接近生产,但容器启动、镜像准备和清理都有成本。把所有测试都压到最重的一层,会让反馈变慢,团队可能逐渐不愿频繁运行。合理方式是分层:大量快速测试覆盖稳定规则,少量真实数据库测试专门守住方言与迁移差异,再用部署前流程验证完整组合。
判断某个测试该放哪里,可以问:失败风险来自我们的业务映射,还是来自具体数据库行为?前者常能由 H2 快速暴露;后者必须让真正的数据库参与。像 PostgreSQL 专属 JSON 类型、全文检索、锁等待和迁移脚本,就不要拿 H2 的通过结果做保证。
接入数据库后,错误信息会变长。先判断失败在哪一层,比从头重写代码有效得多。
看到构造器提示缺少 TaskRepository,先检查 spring-boot-starter-data-jpa 是否存在,再检查主类包位置和 Repository 包位置。主类位于 com.welearn.taskhub 时,com.welearn.taskhub.task 会被默认扫描;若把 Repository 放到根包之外,就需要显式调整扫描范围。不要为了消掉错误,随手在 Service 里 new 一个实现,那会绕开容器和 JPA 基础设施。
这通常意味着 Repository 被发现了,实体却没进入 JPA 元模型。检查是否导入 jakarta.persistence.Entity,是否真的写了 @Entity,以及实体是否在默认扫描范围。复制旧教程的 javax.persistence.Entity,在当前项目里不是等价替换。
最常见原因不是事务没提交,而是 H2 控制台用了另一个 URL。把完整的 jdbc:h2:mem:taskhub;... 原样复制过去,用户名也保持一致。命名内存数据库以连接 URL 区分;多一个名字、少一段配置,都可能连接到另一座库。
org.hibernate.SQL=DEBUG 负责 SQL 模板,org.hibernate.orm.jdbc.bind=TRACE 负责参数绑定。后者没开时看到 ? 是正常表现,不要误以为 Hibernate 真把问号写入了数据库。
先确认实体是在当前事务内查出的托管对象,Service 的公开方法是否经过 Spring Bean 代理调用,方法是否正常提交。如果对象早已分离,单纯调用 setter 不会触发脏检查。反过来,如果只是读取后什么也没改,没有 UPDATE 反而说明脏检查在避免无意义写入。
检查项目中是否同时存在 ddl-auto、schema.sql、data.sql 和迁移工具。开发阶段看似方便的多重保险,往往会造成重复建表或初始化顺序问题。先确定唯一负责人,再让其余配置退出。
先重启应用,确认内存 Repository 的类已经删除,项目中也没有 Service 继续导入旧实现。否则两个 Bean 同时存在时,构造器可能出现候选对象冲突;更隐蔽的情况是旧对象仍被某处直接创建,部分接口走数据库,部分接口还在写 Map。
再按“创建、查询、更新、删除、重查”完整走一轮。只验证 POST 返回 201 不够,因为它只能证明插入路径通了;更新能验证托管状态和版本列,删除后的重查能验证 404 仍由原来的异常处理链产生。客户端契约应与上一节一致,变化只能从 SQL 日志和数据库表中看见。
随后关闭应用并再次启动。任务消失在本节是预期结果,因为使用 H2 内存库和 create-drop。如果你误以为应该保留,这一步正好提醒你区分“进入数据库事务”与“跨进程永久存储”。生产 profile 会改用外部数据库,不能通过悄悄把当前教学 URL 换成文件模式来假装完成生产设计。
最后关闭参数 TRACE 日志或确认它只存在于开发配置。检查 H2 控制台也没有进入生产配置。学习功能跑通只是第一层完成,敏感信息与管理入口没有被意外带出去,才算把环境边界一起守住。
到这里,TaskHub 的 HTTP 契约没有变化,存储核心却完成了替换:Task 成为实体,Spring Data 为 Repository 接口创建代理,Service 用事务包住业务操作,Hibernate 把实体变化翻译成 SQL,H2 执行并保存记录。你也已经能从建表、插入、查询和更新日志反推框架做了什么。
更重要的是,我们没有把 H2 和 ddl-auto=create-drop 包装成生产方案。H2 负责让学习反馈足够快,生产数据库负责真实持久化,迁移工具负责可追踪的 schema 演进,DTO 则把 API 契约从实体变化中隔离开。边界清楚以后,换数据库才不会变成全项目手术。
现在又出现了一个新问题:数据库 URL、日志级别、H2 控制台、服务器端口和生产凭据显然不能永远挤在同一份配置里。下一节我们会把配置外部化,使用 Profile 区分开发与生产,再用 Actuator 检查 TaskHub 和数据库是否真的处于可服务状态。
Service 在方法体内根据当前实体构造 TaskResponse,然后方法正常返回给事务代理。此刻写事务还没有完成最终提交;若 UPDATE 要等提交前刷新才执行,响应对象里可能仍是刷新前的 version。
事务代理开始提交。Hibernate 在刷新阶段执行脏检查,把字段变化合成 UPDATE,并带上 id 和 version 条件。数据库更新成功后 version 增加,事务提交,连接归还连接池。Controller 接到的是已经构造好的 DTO,不会在 JSON 序列化时继续访问数据库。