把 TaskHub 的任务真正存进数据库
上一节,我们把一句简单的问候扩展成了完整的任务 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 换成数据库,不只是换一个容器
内存仓库里,Map<Long, Task> 保存的是 Java 对象引用。Service 查到对象后直接改字段,Map 中的同一个对象也随之变化;编号来自我们写的计数器;应用只要加一把合适的锁,就能在很小的范围内维持并发安全。它没有表、列、SQL、连接和提交这几个概念。
关系数据库的语义更严格。任务要被拆成一行,各字段要落入确定的列;主键生成要由数据库与 JPA 协调;字符串长度、非空值和状态范围可以受到约束;一次请求拿到的是某个事务视角下的数据。修改对象不会在任意时刻立刻变成持久数据,Hibernate 会在刷新或提交时判断应该发出哪些 SQL,数据库再决定这些 SQL 是否满足约束。
查询顺序也是一个典型差别。遍历 Map 得到什么顺序,常常只是当前实现碰巧如此;数据库如果没有 order by,也不承诺返回顺序。后面加入分页时,我们会明确指定 createdAt desc。如果不先建立这个意识,数据少时列表看似稳定,数据一多或执行计划变化,页面就会突然跳动。
还有对象身份问题。同一个持久化上下文内两次按同一主键查任务,Hibernate 会尽量维持“同一数据库行对应同一托管对象”的身份;跨事务后得到的则可能是另一个 Java 实例。判断两个实体是否代表同一条业务记录,要围绕标识设计,不能把内存引用相等当成数据库语义。
所以这次改造虽然只替换 Repository 实现,Service 已经进入了一套新的运行模型。理解事务与实体状态,比记住 save() 方法名更重要。
加入 JPA、H2 与开发控制台
打开项目根目录的 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>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>本课程统一使用 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:
console:
enabled: true
path: /h2-console
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE这份配置看起来长,逐项拆开其实很明确。
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 从普通对象变成 JPA 实体
上一节的 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 class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String title;
@Column(length = 2000)
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private TaskStatus status = TaskStatus.TODO;
@Column(nullable = false)
private int priority;
private LocalDate dueDate;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@Version
private long version;
protected Task() {
}
public Task(String title, String description, int priority, LocalDate dueDate) {
this.title = title;
this.description = description;
this.priority = priority;
this.dueDate = dueDate;
this.createdAt = Instant.now();
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public TaskStatus getStatus() {
return status;
}
public void setStatus(TaskStatus status) {
this.status = status;
}
public int getPriority() {
return priority;
}
public void setPriority(int priority) {
this.priority = priority;
}
public LocalDate getDueDate() {
return dueDate;
}
public void setDueDate(LocalDate dueDate) {
this.dueDate = dueDate;
}
public Instant getCreatedAt() {
return createdAt;
}
public long getVersion() {
return version;
}
}@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 生成边界,不是审计系统。真实项目还可能使用实体回调、审计字段或数据库默认值;本节先把时间由构造器明确生成,保证数据流容易跟踪。
Repository 为什么只写接口就能工作
删除上一节用于保存 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 导入和实体所在包。
让 Service 成为事务的边界
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 List<TaskResponse> list() {
return repository.findAll().stream()
.map(TaskResponse::from)
.toList();
}
@Transactional(readOnly = true)
public TaskResponse get(long id) {
return TaskResponse.from(findTask(id));
}
@Transactional
public TaskResponse create(CreateTaskRequest request) {
var task = new Task(
request.title().trim(),
request.description(),
request.priority(),
request.dueDate());
return TaskResponse.from(repository.save(task));
}
@Transactional
public TaskResponse update(long id, UpdateTaskRequest request) {
var task = findTask(id);
task.setTitle(request.title().trim());
task.setDescription(request.description());
task.setStatus(request.status());
task.setPriority(request.priority());
task.setDueDate(request.dueDate());
return TaskResponse.from(task);
}
@Transactional
public void delete(long id) {
repository.delete(findTask(id));
}
private Task findTask(long id) {
return repository.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
}
}
构造器注入几乎没变,只是内存实现换成了 TaskRepository 接口。Controller 继续依赖同一个 TaskService,客户端也继续发送同样的 JSON。这正是上一节提前分层换来的收益。
@Transactional 的生效方式也值得拆开。容器为 TaskService 创建事务代理;Controller 调用公开的 Service 方法时,先进入代理。代理向事务管理器申请事务,把当前线程上的数据库操作纳入这次事务,然后调用真正的 Service。方法正常返回就提交;默认情况下,方法抛出运行时异常就回滚。注解本身没有提交数据库的能力,完成这些动作的是代理和事务管理器。
事务应该围绕一次业务操作,而不是机械地围绕每一句 SQL。更新任务包含“查出任务、修改字段、写回”三步,它们共同组成一个工作单元。将边界放在 Service,未来即使一次操作需要访问两个 Repository,它们也能参加同一个事务。若只依赖每个 Repository 方法各自的事务,findById() 结束后上下文就断开,后续业务步骤也无法作为整体提交或回滚。
读取方法写 readOnly = true,是在告诉事务基础设施这是读取工作。它主要是一项优化提示,不是数据库权限系统,也不能替代代码审查。写方法使用普通 @Transactional。
更新方法为什么没有再调用 save
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 在方法体内根据当前实体构造 TaskResponse,然后方法正常返回给事务代理。此刻写事务还没有完成最终提交;若 UPDATE 要等提交前刷新才执行,响应对象里可能仍是刷新前的 version。
事务代理开始提交。Hibernate 在刷新阶段执行脏检查,把字段变化合成 UPDATE,并带上 id 和 version 条件。数据库更新成功后 version 增加,事务提交,连接归还连接池。Controller 接到的是已经构造好的 DTO,不会在 JSON 序列化时继续访问数据库。
若第二步找不到任务,或第四步违反数据库约束,代理会走回滚路径。默认回滚规则主要针对运行时异常和错误;受检异常是否回滚要根据事务规则显式决定。入门代码里不要为了“统一处理”把所有异常抓住后只打印日志再正常返回,那会让事务代理误以为方法成功,从而提交本应回滚的修改。
事务持续时间也需要克制。不要在事务里等待用户输入、调用一个可能卡几十秒的远程接口,或者做大量与数据库无关的文件处理。事务越长,连接占用越久,锁和冲突窗口也越大。Service 边界要覆盖完整数据库工作单元,同时避免把整个 Web 请求里所有事情都包进去。
readOnly 不是“绝对禁止写入”
@Transactional(readOnly = true) 会把读取意图传给事务和持久化实现。Hibernate 可以据此减少某些脏检查开销,数据库驱动也可能获得只读提示。但它不是 Java 编译器的限制,不能保证你一写 setter 就立刻报错,不同数据库对只读事务的执行限制也不完全一样。
因此,我们仍然把所有修改集中在普通写事务中,并用测试确认结果。把 readOnly 当成明确意图和优化机会,而不是安全权限。真正的写权限要由数据库账户、应用授权和业务规则共同约束。
实体和 API DTO 为什么必须分开
接入 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,
Instant createdAt,
long version) {
public static TaskResponse from(Task task) {
return new TaskResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.getStatus(),
task.getPriority(),
task.getDueDate(),
task.getCreatedAt(),
task.getVersion());
}
}
上一节已经给请求 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) not null,
description varchar(2000),
primary key (id),
check (status in ('TODO', 'IN_PROGRESS', 'DONE'))
)这段 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 控制台核对 schema
应用保持运行,访问 /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",
"version": 0
}编号现在由数据库生成,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 替你管理生产表结构
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,
version bigint not null default 0
);迁移工具会按版本顺序执行尚未应用的脚本,并在 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 能替你验证什么,又替不了什么
H2 的价值很实际:启动快,进程内就能运行,支持 JDBC、事务和大量标准 SQL。你可以用它确认实体是否被扫描、Repository 代理是否创建、事务是否提交、基本 CRUD 是否生成合理 SQL。这些反馈对刚接触 JPA 的人非常有用。
但 H2 通过了,不等于 PostgreSQL 一定通过。兼容模式只是模拟部分行为,无法复制生产数据库的完整类型系统、执行计划、锁竞争和扩展语法。一个查询可能在 H2 上忽略大小写差异,却在生产库表现不同;某个列名可能在一边合法,在另一边是保留字;并发事务和索引使用更不可能靠一个小型内存数据库完全代替。
比较稳妥的测试分工是:快速的 Repository 测试可以使用 H2;涉及生产方言、迁移脚本、约束、锁和复杂查询的集成测试,用 Testcontainers 启动与生产一致的 PostgreSQL。我们会在测试课程中把这条防线补上。
你还要记住一个容易说错的点:H2 内存模式并没有提供跨重启持久化。我们本节标题所说的“存进数据库”,指数据不再由 Java Map 直接保管,而是进入关系表并接受事务管理。真正跨进程保存,需要 H2 文件模式或外部数据库;TaskHub 的生产方向是后者。
为什么仍然值得先用 H2
知道差异后,有人会走向另一个极端:既然 H2 不能完全代表生产库,那就完全不该使用。这也不准确。测试工具的价值取决于它负责哪一类反馈。H2 能在很短时间内启动,适合反复验证映射、基础 CRUD、事务提交和简单 Repository 行为;开发者每改一小处就能得到反馈,成本很低。
真实 PostgreSQL 测试更接近生产,但容器启动、镜像准备和清理都有成本。把所有测试都压到最重的一层,会让反馈变慢,团队可能逐渐不愿频繁运行。合理方式是分层:大量快速测试覆盖稳定规则,少量真实数据库测试专门守住方言与迁移差异,再用部署前流程验证完整组合。
判断某个测试该放哪里,可以问:失败风险来自我们的业务映射,还是来自具体数据库行为?前者常能由 H2 快速暴露;后者必须让真正的数据库参与。像 PostgreSQL 专属 JSON 类型、全文检索、锁等待和迁移脚本,就不要拿 H2 的通过结果做保证。
按调用链排查最常见的问题
接入数据库后,错误信息会变长。先判断失败在哪一层,比从头重写代码有效得多。
Repository Bean 没有出现
看到构造器提示缺少 TaskRepository,先检查 spring-boot-starter-data-jpa 是否存在,再检查主类包位置和 Repository 包位置。主类位于 com.welearn.taskhub 时,com.welearn.taskhub.task 会被默认扫描;若把 Repository 放到根包之外,就需要显式调整扫描范围。不要为了消掉错误,随手在 Service 里 new 一个实现,那会绕开容器和 JPA 基础设施。
Task 不是 managed type
这通常意味着 Repository 被发现了,实体却没进入 JPA 元模型。检查是否导入 jakarta.persistence.Entity,是否真的写了 @Entity,以及实体是否在默认扫描范围。复制旧教程的 javax.persistence.Entity,在当前项目里不是等价替换。
控制台看不到应用数据
最常见原因不是事务没提交,而是 H2 控制台用了另一个 URL。把完整的 jdbc:h2:mem:taskhub;... 原样复制过去,用户名也保持一致。命名内存数据库以连接 URL 区分;多一个名字、少一段配置,都可能连接到另一座库。
日志只有 SQL,没有参数值
org.hibernate.SQL=DEBUG 负责 SQL 模板,org.hibernate.orm.jdbc.bind=TRACE 负责参数绑定。后者没开时看到 ? 是正常表现,不要误以为 Hibernate 真把问号写入了数据库。
更新后没有 UPDATE
先确认实体是在当前事务内查出的托管对象,Service 的公开方法是否经过 Spring Bean 代理调用,方法是否正常提交。如果对象早已分离,单纯调用 setter 不会触发脏检查。反过来,如果只是读取后什么也没改,没有 UPDATE 反而说明脏检查在避免无意义写入。
启动时 schema 相关配置互相打架
检查项目中是否同时存在 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 和数据库是否真的处于可服务状态。