上一节把 TaskHub 的端口、数据库连接、Profile 和健康检查整理好了。现在应用能启动,也能看见运行状态,但真正的数据压力才刚刚出现:任务从十几条涨到几千条以后,GET /api/tasks 不可能再把整张表一次性搬给客户端;运营同事也不会满足于“列出全部”,他们会同时按状态、关键字、负责人和标签查找。
写操作也变得更像真实业务。把任务从 TODO 改成 IN_PROGRESS 时,我们还要留下一条状态变更记录。两个动作只成功一个,数据就会开始自相矛盾。再考虑两个人同时修改同一项任务,单纯“后一次保存覆盖前一次保存”也不再是可以接受的结果。
这一节不打算把 JPA 的每个功能都摆一遍。我们只沿着两条请求继续搭 TaskHub:一条是带筛选、排序和分页的列表查询,另一条是带事务与并发保护的状态变更。沿途会把关联映射、懒加载、N+1 和审计字段讲透,因为这些问题恰好都发生在 Controller → Service → Repository → 数据库这条请求链上。
本课程统一使用 Spring Boot 4.1.0、Java 25 和 jakarta.* API。网上如果还在使用 javax.persistence.*、XML bean 配置或把实体直接当接口响应,请先确认文章版本;这些写法很容易把旧时代的习惯带进现在的项目。
我们希望列表请求最终长成这样:
GET /api/tasks?status=IN_PROGRESS&keyword=接口&assigneeId=2&tagId=3&page=0&size=5&sortBy=dueDate&direction=asc每个参数只负责一件事:
status、keyword、assigneeId、tagId 是筛选条件,可以全部省略,也可以组合。page 从 0 开始,size 限制在 1..50,避免一个请求取走几十万行。sortBy 只允许 createdAt、dueDate、priority,不把任意字符串直接交给 JPA。direction 只接受 asc 或 desc,默认按创建时间倒序。响应不直接暴露 Spring Data 内部的 PageImpl 结构,而是固定为我们自己的五个分页字段:
{
"content": [
{
"id": 18,
"title": "补上任务状态接口测试",
"description": "覆盖并发更新和回滚场景",
"status": "IN_PROGRESS",
"priority": 5,
"dueDate": "2026-08-20",
"assignee": { "id": 2, "displayName": "小林" },
"tags": [
{ "id": 3, "name": "后端" },
{ "id": 7, "name": "测试" }
],
"createdAt": "2026-08-17T08:30:00Z",
"updatedAt": "2026-08-17T09:12:44Z",
"version": 3
}
],
"totalElements": 6,
"totalPages": 2,
"number": 0,
"size": 5
}状态变更则走一条含义明确的命令接口:
PATCH /api/tasks/18/status
Content-Type: application/json
{
"status": "DONE",
"expectedVersion": 3,
"note": "接口与回滚测试均已通过"
}这里的 expectedVersion 是客户端刚才读到的版本号。它不是数据库主键,也不是“加锁开关”,而是客户端在说:“我修改的是版本 3;如果服务端已经不是版本 3,请不要静默覆盖别人。”

把契约先定下来很有用。后面不管 JPA 生成几条 SQL、关联怎样加载,Controller 都只接参数和返回 DTO;事务边界放在 Service;Repository 只表达数据访问。分层并不是为了多建几个文件,而是为了让 HTTP、业务规则和存储策略可以分别变化。
TaskHub 现在需要表达三个事实:一个任务最多有一个负责人,一个负责人可以负责多个任务;一个任务可以有多个标签,一个标签也可以出现在多个任务上;每次更新都要知道记录在什么时候创建、什么时候修改。
负责人关系适合放在 tasks.assignee_id 外键中,所以任务这一侧使用 @ManyToOne。标签是多对多关系,需要中间表 task_tags(task_id, tag_id)。数据库最终大致是下面四张表:
members
id PK
display_name
tags
id PK
name UNIQUE
tasks
id PK
title
status
priority
due_date
assignee_id FK -> members.id
created_at
updated_at
version
task_tags
task_id FK -> tasks.id
tag_id FK -> tags.id
PRIMARY KEY (task_id, tag_id)Member 和 Tag 都是可以被多项任务共享的独立实体。它们的核心映射保持简单:
package com.welearn.taskhub.task;
import jakarta.persistence.*;
@Entity
@Table(name = "members")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "display_name", nullable = false,
package com.welearn.taskhub.task;
import jakarta.persistence.*;
@Entity
@Table(name = "tags", uniqueConstraints =
@UniqueConstraint(name = "uk_tag_name", columnNames = "name"))
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Task 在上一节已有标题、状态、优先级、截止日期和 @Version。现在把关系与审计字段接进去,省略未变化的普通 getter:
package com.welearn.taskhub.task;
import jakarta.persistence.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashSet;
import java.util.Set;
@Entity
@Table(name = "tasks")
@EntityListeners(AuditingEntityListener.class)
public class Task
@ManyToOne 的默认抓取方式本来是 EAGER,但我们明确写成 LAZY。这句话的真实含义是:加载 Task 时,先不要求把 Member 的全部列也加载出来;只有代码真正访问负责人时,Hibernate 才去初始化关联。@ManyToMany 默认就是 LAZY,我们仍显式写出,让阅读代码的人不必靠记默认值猜行为。
Task.tags 是中间表的维护方,因为 @JoinTable 写在这一侧。给任务加标签时,Hibernate 根据这个集合的变化维护 task_tags。本节只需要从 Task 导航到 Tag,因此没有急着在 Tag 里再放一个 Set<Task>。双向关联会要求两侧同时维护,JSON 序列化时还可能互相引用;没有业务需要时,单向关系更省心。
还有一个刻意没有加的东西:cascade = CascadeType.ALL。删除一项任务时,我们只想删除 task_tags 中的连接行,不想顺手删掉共享的负责人和标签。级联不是“让 JPA 自动处理一切”的按钮,它表达的是两个对象在生命周期上是否从属于彼此。共享对象通常不属于某一项任务。

@CreatedDate 和 @LastModifiedDate 只是元数据。真正执行赋值的是 AuditingEntityListener,它在实体持久化和更新的生命周期回调中写入时间;@EnableJpaAuditing 则把这套审计基础设施注册进 Spring 容器。新建配置类:
package com.welearn.taskhub.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaAuditConfig {
}本节只记录创建和修改时间,不记录“谁修改”。第十节接入认证以后,可以再提供 AuditorAware,把当前登录人写入 @CreatedBy 和 @LastModifiedBy。不要现在硬编码一个用户名,那会让审计记录看起来存在,实际上却没有可信身份来源。
updatedAt 只回答“最后一次什么时候改”,不能代替状态历史。想知道任务从哪个状态变到哪个状态、备注是什么,仍要单独保存 TaskStatusHistory。快照字段与变更流水解决的是两个不同问题。
Spring Data JPA 会解析 Repository 方法名,并在启动时为接口创建代理实现。按一个固定条件查询时,派生方法非常合适:
public interface TaskRepository extends JpaRepository<Task, Long> {
Page<Task> findByStatus(TaskStatus status, Pageable pageable);
}方法名中的 findBy、Status 会被解析成属性条件,Pageable 则把分页和排序附加到查询上。我们没有写实现类,但也不是编译器凭空生成了 SQL;运行时真正接住调用的是 Spring Data 创建的 Repository 代理。
标题模糊搜索也可以用 JPQL 表达:
@Query("""
select t
from Task t
where lower(t.title) like lower(concat('%', :keyword, '%'))
""")
Page<Task> searchByTitle(@Param("keyword") String keyword, Pageable pageable);JPQL 使用实体名和 Java 属性名,所以这里是 Task 和 title,不是表名 tasks。只有使用原生 SQL 时才直接面对表名、列名和数据库方言。
问题出在组合条件。假如我们继续用方法名,很快会得到这样的接口:
Page<Task> findByStatusAndTitleContainingIgnoreCaseAndAssigneeIdAndTagsId(
TaskStatus status,
String keyword,
Long assigneeId,
Long tagId,
Pageable pageable);更麻烦的是四个条件都可选。为了覆盖每种组合,你会继续添加“有状态无标签”“有负责人无关键字”等方法。方法名没有错,是需求已经从固定查询变成动态查询,此时应该换工具。
Specification<Task> 可以理解成一段“针对 Task 的查询条件”。它最终还是使用 JPA Criteria API 生成数据库谓词,只是把每个条件拆成可组合的小函数。Repository 需要额外继承 JpaSpecificationExecutor<Task>:
package com.welearn.taskhub.task;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface TaskRepository
extends JpaRepository<Task, Long>, JpaSpecificationExecutor<Task> {
@EntityGraph(attributePaths = {"assignee"
另外三个实体各自保留一个很薄的 Repository 接口。它们必须放在各自同名文件中;接口本身不需要手写实现:
public interface MemberRepository extends JpaRepository<Member, Long> {
}
public interface TagRepository extends JpaRepository<Tag, Long> {
}
public interface TaskStatusHistoryRepository
extends JpaRepository<TaskStatusHistory, Long> {
}Spring Data 会分别创建三个代理 Bean。让它们保持简单是有意为之:负责人查找、标签存在性检查和历史保存的组合顺序属于业务用例,应由 Service 决定,而不是为了“让 Repository 看起来厉害”把整段业务搬进接口。
查询参数用独立 record 接收。它属于“查询条件”,不要塞进 Task 实体:
package com.welearn.taskhub.task;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
public record TaskQuery(
TaskStatus status,
@Size(max = 80, message = "关键字不能超过 80 个字符")
String keyword,
@Positive(message = "负责人编号必须是正整数")
Long assigneeId,
@Positive(message = "标签编号必须是正整数")
接着为每个条件写一个小的 Specification,再由 from 方法决定是否拼接:
package com.welearn.taskhub.task;
import jakarta.persistence.criteria.JoinType;
import org.springframework.data.jpa.domain.Specification;
import java.util.Locale;
public final class TaskSpecifications {
private TaskSpecifications() {
}
public static Specification<Task> from(TaskQuery query) {
var specification = Specification.<Task>unrestricted();
if
这里有几个值得停下来看的细节。
第一,Specification.unrestricted() 表示“当前没有附加条件”。Spring Data JPA 4.1 不需要我们传 null 假装没有条件,后面的 .and(...) 会自然地组合存在的条件。
第二,关键字同时匹配标题和描述,所以内部是 or;关键字这一组又要和状态、负责人、标签同时成立,所以外部用 and。如果不先把括号关系想清楚,很容易写出“标题命中就无视状态”的错误查询。
第三,% 和 _ 在 LIKE 中是通配符。用户搜索 100% 时,多半真的想找百分号,不是想扩大匹配范围。escapeLike 先转义,再把反斜杠告诉 Criteria API,查询的语义才和输入一致。参数值仍由 JPA 绑定,不要用字符串拼接 JPQL。
第四,筛选标签时才 join tags。我们没有把关联统一改成 EAGER,也没有让每次查询都连接标签表。查询计划应该跟着用例走,而不是永远由实体上的一个全局开关决定。

测试不要只验证“返回了数据”,要让错误组合确实会失败。下面的数据中,只有第一项同时满足状态、关键字和标签:
@DataJpaTest
class TaskSpecificationTest {
@Autowired TaskRepository taskRepository;
@Autowired TagRepository tagRepository;
@Test
void combinesStatusKeywordAndTag() {
var backend = tagRepository.save(new Tag("后端"));
var testing = tagRepository.save(new Tag("测试"));
var
这段测试固定的是查询语义,而不是 Hibernate 某次生成的别名。SQL 别名可能随版本调整,但“哪些任务应该被选中”才是业务真正关心的契约。
分页必须在数据库查询阶段发生。下面这种写法表面也返回一页,实际上先把所有行装进 JVM,再用 subList 截取;数据越多,内存和响应时间越糟:
// 反例:不要先 findAll() 再在内存里分页
var allTasks = repository.findAll();
var currentPage = allTasks.subList(fromIndex, toIndex);正确做法是创建 Pageable,让 Hibernate 把 limit、offset 和 order by 交给数据库:
public enum SortDirection {
asc, desc
}private static final Map<String, String> SORT_FIELDS = Map.of(
"createdAt", "createdAt",
"dueDate", "dueDate",
"priority", "priority");
private Pageable toPageable(
int page,
int size,
String sortBy,
SortDirection direction) {
var property = SORT_FIELDS.get
public class UnsupportedSortFieldException extends RuntimeException {
public UnsupportedSortFieldException(String field) {
super("不支持的排序字段:" + field);
}
}为什么不直接写 Sort.by(sortBy)?因为 Web 参数是外部输入。即使 JPA 不会把它原样拼成一段 SQL 注入,任意属性路径仍可能让调用者探测内部模型、触发昂贵 join,或制造运行时错误。白名单同时稳定了公开 API 和查询成本。
第二排序键也很关键。假设十项任务的 createdAt 完全相同,只按这个字段排序时,数据库没有义务每次用同一顺序返回它们。用户翻到下一页时可能看到重复项或漏项。再按唯一 id 排序,就给结果建立了确定顺序。
我们定义自己的分页响应:
package com.welearn.taskhub.task;
import org.springframework.data.domain.Page;
import java.util.List;
public record TaskPageResponse<T>(
List<T> content,
long totalElements,
int totalPages,
int number,
int size) {
public static <T> TaskPageResponse<T> from(Page<T> page) {
Page 通常会执行两类查询:一条取当前页内容,一条计算匹配总数。客户端需要显示“共 12 页”时,这个 count 是有价值的。如果页面只需要“加载更多”按钮,只关心有没有下一页,可以改用 Slice,省掉总数查询。不要在接口已经承诺总页数后偷偷换成 Slice;先从产品需要反推返回类型。
@Service
public class TaskService {
private final TaskRepository taskRepository;
private final MemberRepository memberRepository;
private final TagRepository tagRepository;
private final TaskStatusHistoryRepository historyRepository;
public TaskService(
TaskRepository taskRepository,
MemberRepository memberRepository,
TagRepository tagRepository,
TaskStatusHistoryRepository historyRepository) {
this.taskRepository =
readOnly = true 会把只读意图传给事务管理器和 JPA 提供者,Hibernate 可以减少不必要的脏检查。它是优化提示,不是权限系统;不能指望它替你阻止所有写操作。
四参数 list 是一个有意保留的兼容入口。下一节的 Thymeleaf 看板只需要状态、关键字和默认排序,可以继续调用 service.list(status, keyword, page, size);它最终仍委托给同一套 Specification 与分页实现,不会复制查询逻辑。
更重要的是,TaskResponse.from(task) 仍在事务方法内部执行。映射器读取懒加载的负责人和标签时,Task 仍属于当前持久化上下文。等方法返回,Controller 拿到的已经是普通 DTO,不需要再碰实体代理。
@RestController
@RequestMapping("/api/tasks")
@Validated
public class TaskController {
private final TaskService service;
public TaskController(TaskService service) {
this.service = service;
}
@GetMapping
public TaskPageResponse<TaskResponse> list(
@Valid @ModelAttribute
Spring MVC 的参数解析器把查询字符串绑定到 TaskQuery,Bean Validation 检查长度和正整数约束;Service 不需要知道 @RequestParam。请求通过后,生成的主要 SQL 形态会接近:
select t.*
from tasks t
join task_tags tt on tt.task_id = t.id
where t.status = ?
and (lower(t.title) like ? escape '\'
or lower(t.description) like ? escape '\')
and
具体分页语法会随数据库方言变化,查询含义不变。这里展示两条 SQL,是为了让你知道 Page 的总页数不是免费出现的。
很多人第一次遇到 LazyInitializationException,会立刻把所有关系改成 EAGER。先别急。我们看一个常见坏版本:
// Service 返回实体,事务在 return 之后结束
@Transactional(readOnly = true)
public Task findEntity(long id) {
return taskRepository.findById(id).orElseThrow();
}// Controller 在事务外才读取 tags
@GetMapping("/{id}/bad")
public Task badGet(@PathVariable long id) {
return service.findEntity(id);
}应用配置了 spring.jpa.open-in-view=false 后,Service 返回时持久化上下文已经关闭。JSON 序列化器再访问 task.getTags(),Hibernate 只剩一个尚未初始化的懒加载集合,却没有可用 Session 去查数据库,于是抛出异常:
org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role: Task.tags
could not initialize proxy - no Session异常不是在说 LAZY 本身错了,而是在说代码把“什么时候读数据库”拖到了业务事务之外。把关系全部改成 EAGER 只是遮住这个边界错误,还会让不需要标签的查询也付出加载成本。
TaskHub 采用的规则很朴素:实体留在 Service/Repository 边界内;Service 在事务中把需要的字段读完并转换成 DTO;Controller 只拿 DTO。开发配置保留:
spring:
jpa:
open-in-view: false这项配置会让越界访问尽早暴露。相比让一次数据库会话一直拖到 JSON 或页面渲染结束,失败得早通常更容易修。
修好懒加载异常以后,列表可能正确返回,却悄悄发出很多 SQL。假设第一页有 5 项任务,DTO 映射逐项读取负责人和标签,最坏会看到这样的日志:
select t.* from tasks t order by t.created_at desc fetch first 5 rows only;
select m.* from members m where m.id = ?; -- 第 1 项任务的负责人
select g.* from tags g join task_tags tt on g.id = tt.tag_id where tt
第一条查出 N 项 Task,随后每项又触发关联查询,这就是 N+1。这里甚至可能接近 1 + N + N。第一页只有 5 条时不显眼,页大小变成 50、数据库与应用分开部署后,来回网络延迟会被放大。

前面 Repository 的 findDetailById 带了:
@EntityGraph(attributePaths = {"assignee", "tags"})
@Query("select t from Task t where t.id = :id")
Optional<Task> findDetailById(@Param("id") long id);@EntityGraph 告诉 JPA:执行这一条详情查询时,负责人和标签属于本次用例所需数据。它改变的是该查询的抓取计划,不需要把实体的全局映射改成 EAGER。查询一个 Task 并抓取一个 to-one 与一个集合,结果规模可控。
分页列表不能随手对 to-many 集合做 fetch join。一个 Task 会因多个标签扩成多行,数据库分页面对的是连接后的行,Task 页的语义会变得复杂;某些方言或 Hibernate 版本还可能把分页退回内存处理。一个更稳妥的入门方案是保留主查询分页,再让 Hibernate 批量初始化当前页遇到的代理:
spring:
jpa:
open-in-view: false
properties:
hibernate:
default_batch_fetch_size: 50映射当前页 DTO 时,SQL 会收敛成类似下面几组:
-- 1. 当前页任务
select t.*
from tasks t
order by t.created_at desc, t.id asc
offset 0 rows fetch first 5 rows only;
-- 2. Page 需要的总数
select count(t.id) from tasks t;
-- 3. 当前批次涉及的负责人
select m.* from members m where
查询数量不再随每项任务线性增加。50 不是神奇数字,它应结合列表上限与实际数据观察;这里恰好与接口允许的最大页大小一致。
批量抓取不是所有查询的终点。如果列表只展示任务标题、状态和负责人名称,专门的 DTO 投影可能更省列、更直接;如果只查一个详情,EntityGraph 很清楚;如果是一个受控的 to-one 关联,fetch join 也合适。优化要围绕具体读模型做,不要把“统一改 EAGER”当通用答案。
开发环境可以临时增加:
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE先数一遍一次 HTTP 请求发了多少条 SQL,再看哪些语句重复、是否使用预期排序和过滤。不要只盯着单条 SQL 是否很短;十条很短的远程查询,通常仍比一两条边界清楚的查询慢。
关联更新不是把客户端传来的 ID 直接塞进实体。Service 要先确认负责人和每个标签确实存在,再一次性更新关系:
public record UpdateTaskRelationsRequest(
@Positive Long assigneeId,
Set<@Positive Long> tagIds) {
public UpdateTaskRelationsRequest {
tagIds = tagIds == null ? Set.of() : Set.copyOf(tagIds);
}
}public class InvalidTaskRelationException extends RuntimeException {
public InvalidTaskRelationException(String message) {
super(message);
}
}@Transactional
public TaskResponse updateRelations(long taskId, UpdateTaskRelationsRequest request) {
var task = taskRepository.findDetailById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
Member assignee = null;
if (request.assigneeId() != null) {
assignee = memberRepository.findById(request.assigneeId())
.
findAllById 返回数量小于请求 ID 数量时,说明至少有一个标签不存在。这里不能静默忽略坏 ID,否则客户端以为三个标签都绑定成功,数据库里却只有两个。
Task 是当前事务中的托管实体。调用 assignTo 和 replaceTags 后,Hibernate 在 flush 时通过脏检查生成 update tasks、删除旧连接行和插入新连接行。代码没有显式调用 save(task) 也能更新,这是持久化上下文的工作方式;flush() 放在返回前,是为了让约束或乐观锁冲突在当前方法内暴露,并让响应中的版本号是刷新后的值。
请求:
PATCH /api/tasks/18/relations
Content-Type: application/json
{
"assigneeId": 2,
"tagIds": [3, 7]
}成功响应中的关系部分是:
{
"id": 18,
"assignee": { "id": 2, "displayName": "小林" },
"tags": [
{ "id": 3, "name": "后端" },
{ "id": 7, "name": "测试" }
],
"version": 4
}现在实现状态变更。除了更新 tasks.status,还要向 task_status_history 插入流水:
@Entity
@Table(name = "task_status_history")
public class TaskStatusHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "task_id", nullable = false)
private Long taskId;
@Enumerated(EnumType.STRING)
@
先看一个失败反例:
// 反例:两个 Repository 调用没有被同一个 Service 事务包住
public void changeStatusBadly(long id, TaskStatus next, String note) {
var task = taskRepository.findById(id).orElseThrow();
var previous = task.getStatus();
task.changeStatusTo(next);
taskRepository.save(task); // 可能已经在自己的事务中提交
historyRepository.save(
new TaskStatusHistory(id, previous, next, note)); // 此处失败
}Spring Data 的常规写方法本身有事务能力,但这不代表两次 Repository 调用天然属于同一个事务。如果外层 Service 没有事务,第一次保存可能已经提交;第二次插入因约束、连接中断或其他运行时异常失败,任务状态变了,历史却缺了一条。
正确边界放在公开的 Service 方法上:
public record ChangeTaskStatusRequest(
@NotNull(message = "目标状态不能为空")
TaskStatus status,
@NotNull(message = "版本号不能为空")
@PositiveOrZero(message = "版本号不能为负数")
Long expectedVersion,
@Size(max = 200, message = "状态说明不能超过 200 个字符")
String note) {
}版本不匹配使用专门异常,不和“任务不存在”或普通参数错误混在一起:
public class StaleTaskVersionException extends RuntimeException {
public StaleTaskVersionException(
long taskId, long expectedVersion, long currentVersion) {
super("任务 %d 的期望版本是 %d,当前版本是 %d"
.formatted(taskId, expectedVersion, currentVersion));
}
}@Transactional
public TaskResponse changeStatus(long taskId, ChangeTaskStatusRequest request) {
var task = taskRepository.findDetailById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
if (task.getVersion() != request.expectedVersion()) {
throw new StaleTaskVersionException(
taskId, request.expectedVersion(), task.getVersion());
}
Controller 的两个命令入口只做路径、请求体验证和状态码转换,仍然不直接操作 Repository:
@PatchMapping("/{id}/status")
public TaskResponse changeStatus(
@PathVariable @Positive long id,
@Valid @RequestBody ChangeTaskStatusRequest request) {
return service.changeStatus(id, request);
}
@PatchMapping("/{id}/relations")
public TaskResponse updateRelations(
@PathVariable @Positive long id,
Spring 为这个 Bean 创建事务代理。Controller 调用代理上的 changeStatus 时,代理先开启或加入事务,再执行方法;方法正常完成就提交,未处理的运行时异常向外抛出时回滚。事务会同时覆盖 Task 的脏检查更新与 history 的插入。

为了验证“先改状态,后面失败”不会留下半成品,可以在测试配置中放一个只用于制造异常的事务 Bean。它不会进入生产代码:
@SpringBootTest
class TaskRollbackTest {
@Autowired TaskRepository taskRepository;
@Autowired RollbackProbe rollbackProbe;
@TestConfiguration(proxyBeanMethods = false)
static class ProbeConfig {
@Bean
RollbackProbe rollbackProbe(TaskRepository repository) {
return new RollbackProbe(repository);
}
}
static class
测试期间能看到 update 已经因 flush() 发给数据库,但事务最后仍然回滚。重新查询时状态是 TODO:
before: status=TODO
SQL: update tasks set status='IN_PROGRESS', version=1 where id=? and version=0
error: java.lang.IllegalStateException: 后续步骤失败
transaction: rolled back
after: status=TODO, version=0这正是事务的价值:它保护的是一个业务动作,不是某一条 SQL。
其一,默认回滚规则针对 RuntimeException 和 Error。如果业务方法抛的是受检异常,并且也希望回滚,要显式使用 @Transactional(rollbackFor = SomeCheckedException.class),或者把异常模型调整为合适的运行时业务异常。
其二,事务能力来自代理调用。一个类内部用 this.otherTransactionalMethod() 自己调用自己,通常不会再次经过代理,因此被调用方法上的新事务语义不会生效。把完整业务边界放在外部可调用的公开 Service 方法上,比在许多 private 方法上到处贴注解更可靠。
其三,事务要短。不要在持有数据库事务时等待邮件接口、文件上传或一个可能数秒不返回的远程 HTTP 请求。先完成本地一致性写入;跨系统可靠投递需要 outbox 等更完整的设计,不是把数据库事务拉长就能得到。
@Version 字段并不会把表锁住。Hibernate 更新 Task 时,会把读到的版本放进 WHERE,并在成功后递增版本:
update tasks
set status = ?, updated_at = ?, version = ?
where id = ? and version = ?;假设甲和乙都读到任务 18 的 version=3:
甲先提交。数据库中的 WHERE 能匹配 id=18 and version=3,更新一行,版本变成 4。
乙随后仍用版本 3 更新。数据库已经没有 id=18 and version=3 这一行,因此更新行数为 0。
Hibernate 将“预期更新一行,实际更新零行”转换成乐观锁异常。接口把它映射为 409 Conflict,要求乙重新读取后再决定。

我们的状态命令还比较了 expectedVersion。这一步处理“客户端拿着很久以前的页面,过一会儿才提交”的情况;JPA 在 flush 时的版本条件则处理“两个事务几乎同时读、同时写”的竞态。两层检查目标一致,但发生时机不同。
统一异常处理可以返回清楚的 409:
@ExceptionHandler({
StaleTaskVersionException.class,
ObjectOptimisticLockingFailureException.class
})
ProblemDetail handleConflict(Exception exception) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"任务已被其他请求修改,请重新获取后再提交");
problem.setTitle("任务版本冲突");
return problem;
}排序白名单错误则单独映射为 400,和并发冲突区分开:
@ExceptionHandler(UnsupportedSortFieldException.class)
ProblemDetail handleUnsupportedSort(UnsupportedSortFieldException exception) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, exception.getMessage());
problem.setTitle("排序参数无效");
return problem;
}不存在的负责人或标签是本次请求引用了无效资源,同样返回 400,而不是让数据库异常变成 500:
@ExceptionHandler(InvalidTaskRelationException.class)
ProblemDetail handleInvalidRelation(InvalidTaskRelationException exception) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, exception.getMessage());
problem.setTitle("任务关联参数无效");
return problem;
}两个请求都携带版本 3 时,结果应是一个成功、一个冲突:
PATCH /api/tasks/18/status -> 200 OK
{
"id": 18,
"status": "DONE",
"version": 4
}
PATCH /api/tasks/18/status -> 409 Conflict
{
"title": "任务版本冲突",
"status": 409,
"detail": "任务已被其他请求修改,请重新获取后再提交",
"instance": "/api/tasks/18/status"
}客户端收到 409 后不要自动把旧请求无限重放。它应该重新 GET 最新任务,把新旧内容展示给用户或重新应用仍然成立的修改。
如果某个很短的数据库操作竞争极其激烈,而且业务宁愿等待也不接受冲突重试,可以为专用查询加悲观写锁:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from Task t where t.id = :id")
Optional<Task> findByIdForUpdate(@Param("id") long id);它依靠数据库的行锁工作,并且必须在事务里使用。代价是等待、超时和死锁风险,所以 TaskHub 的普通编辑仍优先使用 @Version。不要因为“锁听起来更安全”就让所有读写排队。
刚接触 JPA 时,最容易把 save()、flush() 和事务提交当成同一件事。它们经常挨着出现,但含义不同。
实体从 Repository 查询出来后,会进入当前持久化上下文,变成托管实体。调用 task.changeStatusTo(...) 只是先改变 Java 对象,Hibernate 记录下“它和最初快照不一样了”。这个阶段数据库里可能还没有任何变化。
flush 是把持久化上下文中积累的变化同步成 SQL。Hibernate 会决定 INSERT、UPDATE、DELETE 的顺序,把语句发给数据库,并在此时发现非空约束、唯一约束或版本号冲突。repository.flush() 能让这些问题早点出现在业务方法内部,但 flush 仍不等于提交:只要外层事务最后回滚,刚才执行过的 SQL 也不会成为其他事务可见的最终结果。
提交发生在事务代理准备结束方法调用时。数据库确认整组修改后,事务才真正完成。因此下面这段代码即使已经 flush,后面的运行时异常仍会撤销更新:
@Transactional
public void changeThenFail(long id) {
var task = taskRepository.findById(id).orElseThrow();
task.changeStatusTo(TaskStatus.IN_PROGRESS);
taskRepository.flush(); // SQL 已发送,但事务还没提交
throw new IllegalStateException("后续步骤失败");
}反过来,如果没有外层事务,先后两次 Repository 写调用可能各用一个很短的事务。第一步返回时已经提交,第二步再失败就来不及撤回第一步。这也是我们坚持把事务写在 Service 业务方法上的原因:Service 知道“改状态 + 写历史”是一个整体,单个 Repository 只知道自己正在保存一类数据。
另一个危险写法是在事务方法内部捕获数据库异常,然后返回一个“失败”对象:
@Transactional
public boolean changeStatusBadly(long id) {
try {
// 修改任务并保存历史
return true;
} catch (DataAccessException exception) {
log.warn("保存失败", exception);
return false;
}
}有些持久化异常已经让当前事务被标记为 rollback-only。你把异常吞掉后,外层代理以为方法正常返回,尝试提交时才抛出 UnexpectedRollbackException;调用者看到的错误位置与真正原因相隔很远。更糟的是,如果捕获的是业务异常且事务尚未被标记回滚,方法还可能提交你原本不想保留的部分修改。
更清楚的做法是让异常越过事务边界,再由 @RestControllerAdvice 统一转成 HTTP 错误。如果确实要捕获并换成自己的业务异常,也要保留原异常作为 cause,并确保新的异常仍符合预期回滚规则:
try {
historyRepository.save(history);
taskRepository.flush();
} catch (DataAccessException exception) {
throw new TaskChangeFailedException("状态变更没有保存", exception);
}“只读为什么还要事务”也是常见疑问。关系型数据库的读取同样发生在事务语境里;JPA 的持久化上下文也需要一个清楚的生命周期。列表查询、批量初始化关联、DTO 映射放在同一个只读事务中,可以让这一组读取共享上下文与一级缓存,并保证懒加载发生在可控范围。
readOnly = true 不是把数据库变成不可写模式的安全墙。它主要表达意图,并允许驱动或 Hibernate 做优化。真正的写权限由数据库账户与应用授权控制;业务正确性靠代码和测试控制。不要故意在只读事务里修改实体,再依赖某个数据库“应该拒绝”。
关联映射的注解不算多,难点在于对象模型和表模型有不同规则。下面四个失败现场比背诵注解参数更有用。
如果在 Task.tags 上配置 cascade = CascadeType.ALL,删除任务时级联 REMOVE 可能尝试删除 Tag。可“后端”标签还被其他任务使用,轻则触发外键约束异常,重则在错误的映射下删掉共享数据。
判断级联时先问生命周期:没有这项 Task,Tag 是否仍有独立意义?答案是有,所以 Task 不拥有 Tag 的生命周期,只拥有 task_tags 中的关联。创建新标签应走 Tag 自己的服务;删除任务只清连接行。
负责人也一样。解除分配只是把 assignee_id 设为 null,不等于删除 Member。数据库外键描述引用,级联描述生命周期,两者不要混为一谈。
如果给 Member 添加 Set<Task> tasks,给 Tag 也添加 Set<Task> tasks,每次关系变化都要维护两侧对象:
public void addTag(Tag tag) {
tags.add(tag);
tag.getTasks().add(this);
}漏一侧时,当前内存对象和数据库写入意图可能不一致。再把实体交给 JSON 序列化器,就可能沿 Task → Tag → Task 无限递归。双向关系只在业务确实需要从两侧导航时引入;查询“某标签下的任务”完全可以由 TaskRepository 的 join 条件完成,不要求 Tag 永远持有反向集合。
Task 的 tags 使用 Set。Set 依赖元素的 equals 与 hashCode 保持稳定。如果 Tag 的 hashCode 包含 name,而 name 又允许修改,放进 Set 后改名就可能让集合再也找不到原元素。把整个 tasks 反向集合塞进 equals 更危险,会触发懒加载、递归比较和巨大查询。
实体相等策略需要结合标识生成方式谨慎设计。这个项目不靠“比较整个对象图”判断同一标签,关系更新使用数据库 id 查出托管实体。没有明确策略前,不要让 IDE 一键把所有字段都生成进 equals/hashCode。
下面的 setter 看似省事:
public void setTags(Set<Tag> tags) {
this.tags = tags;
}实体加载后,this.tags 可能是 Hibernate 用来跟踪增删的持久化集合包装。直接换成外部集合,会丢掉框架正在维护的快照,还可能把不可变 Set.of(...) 留在实体中,后续增删时报错。replaceTags 采用 clear() 再 addAll(),保留原集合实例,变化也更容易被脏检查识别。
这并不是要求所有实体都写大量 setter。恰恰相反,领域方法应表达动作:assignTo、replaceTags、changeStatusTo 比 setAssignee、setStatus 更容易放进业务约束。实体不只是表的一份 Java 镜像,它还守住自身状态是否合法。
前面的分页接口已经能用,但“有 limit 和 offset”不代表所有边界都处理好了。
offset 分页适合普通后台列表:页码直观,可以跳到第几页,总数也容易表达。不过请求 page=10000&size=50 时,数据库通常仍要识别并跳过前面的五十万行,然后才返回 50 行。索引能减少排序成本,却不能让超大 offset 完全免费。
如果未来做无限滚动或事件时间线,可以改为“从上一条记录的 (createdAt,id) 之后继续”,也就是游标或 keyset 思路。它要求稳定排序键,也不适合随意跳页。TaskHub 当前是运营看板,页数不会无限增长,所以先保留 Page;这叫按用例选方案,不是忽略更高级 API。
按 dueDate 排序时,没有截止日期的任务放前面还是后面,不同数据库默认可能不同。若产品依赖固定规则,应把规则写进专门查询或数据库兼容的排序表达式,并为 H2 与 PostgreSQL 都写测试。当前接口只承诺方向和 id 次序,没有承诺 null 的位置,因此页面不应偷偷依赖开发数据库的偶然顺序。
如果业务决定“无截止日期永远最后”,可以在读模型中明确设计,而不是让前端看到线上顺序变化才补救。
当前 tagId=3 是等值条件,中间表的 (task_id,tag_id) 唯一,一项任务对同一标签最多产生一行。如果以后支持 tagIds=3,7 并表达“匹配任意标签”,同一任务可能同时命中两个标签,join 结果就有重复 Task。此时要明确是 distinct、分组,还是子查询,并验证 count 统计的也是任务数而不是连接行数。
不要看到重复就到处加 distinct。它可能让 count 更昂贵,也可能掩盖关系表本不该有的重复数据。先写出集合语义:“任意标签”还是“同时拥有所有标签”,再选择 SQL 形态。
内容查询命中索引很快,复杂 join 的 count 却可能扫描大量匹配行。处理顺序应该是:先看接口是否必须显示总数;必须显示就检查条件、索引和执行计划;只需“还有下一页”就考虑 Slice。不要返回一个虚假的估算 totalElements,也不要为了省 count 改掉已经公开的响应语义却不通知客户端。
“并发更新”常被当成一个笼统概念,实际上 TaskHub 面对至少两类时间线。
第一类是同时竞争。甲乙几乎同时开始事务,都从数据库读到版本 3。甲先 flush 成功,乙随后 flush 更新零行。实体上的 @Version 能直接发现这种竞争,因为两个持久化上下文各自保留了加载时版本。
第二类是陈旧页面。乙早上读到版本 3,期间甲已经把任务改成版本 4并提交。下午乙才点击保存。如果乙的新事务这时重新查询 Task,它读到的已经是版本 4;仅靠本次事务内部的 @Version,后续更新可以正常成功,因为“加载到 flush 之间”没人再改。可乙的输入其实基于旧页面,仍可能覆盖甲的含义。
这就是状态请求携带 expectedVersion 的原因。Service 在修改前比较“客户端见过的版本”和“当前数据库版本”,把陈旧页面也转成冲突。flush 时的 JPA 版本条件继续保护极短窗口内的新竞争。
版本冲突和重复请求也不是一回事。用户双击按钮、网关因超时重试,可能把完全相同的命令发送两次。版本号通常会让第二次变成 409,但有些创建或外部扣款操作需要按请求标识实现幂等。不要把 @Version 宣传成所有并发与重试问题的万能解法。
只返回“保存失败”会让用户不知道能不能重试。409 响应至少要表达任务已经变化,客户端应重新读取。需要更友好时,可以附上当前版本或最新资源地址,但不要在异常处理器里再次执行一大串带懒加载的实体序列化。
业务上也要决定能否自动合并。甲改标题、乙改截止日期,看起来字段不重叠,系统或许可以合并;甲将状态改为 DONE、乙又把它改回 TODO,通常需要人判断。本节选择保守策略:检测到版本不一致就拒绝,让客户端基于最新状态重新提交。规则简单,也最不容易静默丢数据。
遇到报错时,先把它放回请求链,而不是轮流尝试 EAGER、@Transactional 和 distinct。
先看 Service 是否返回了实体,DTO 映射是否发生在事务结束之后。再看所需关联是否属于这次用例的抓取计划。正确修复通常是把映射移进只读事务,并用 EntityGraph、fetch join、批量抓取或投影取齐所需数据。
不要用重新打开 open-in-view 当第一反应。那会让 Controller 或模板中的一个 getter 也能临时查数据库,表面不报错,查询位置却更难追踪。
先把同一次请求的 SQL 按关联分组。如果主查询只有一条,随后不断出现 where member.id=? 或 where task_id=?,就是典型 N+1。详情与列表采用不同抓取策略,不要为了列表一次加载而污染所有查询。
修复后再次数 SQL。优化不是“加了注解就算完成”,而是相同页大小下,语句数量不再随 N 线性增长,并且返回内容保持一致。
检查 Specification 是否 join 了 to-many 关系,一个 Task 是否能匹配多条连接行。确认业务需要任意标签还是全部标签,再决定 distinct、group by 或子查询。内容查询和 count 查询必须使用一致的“按 Task 计数”语义。
只在 Java 里对返回 List 去重不能修复 totalElements,也可能让一页原本应有 20 项最后只剩 13 项。
确认实体是不是托管状态,方法是否真的经由事务代理调用,是否在只读事务里修改,以及是否提前 clear/关闭了持久化上下文。若对象是客户端自行构造的 detached entity,把它当成数据库当前状态直接覆盖,会丢掉未携带字段;更稳妥的是先按 id 加载托管实体,再通过领域方法修改允许变化的字段。
开启 SQL 日志检查有没有 UPDATE。如果完全没有,问题多半发生在脏检查之前;如果有 UPDATE 但事务回滚,要继续找后续异常与 rollback-only 标记。
这通常说明保护正在起作用,不是随机数据库故障。日志应带 taskId、请求见到的版本、当前版本和请求关联 ID,方便还原竞争,但不要把整份请求体或敏感数据无选择地写入日志。
如果冲突频率极高,再分析是否存在热点任务、前端是否长时间保留旧页面、是否有无意义的重复保存。只有确认等待比冲突重试更合适,才为特定用例考虑悲观锁。
H2 能快速验证实体映射和 Repository 语义,但它不是 PostgreSQL 的完整替身。null 排序、大小写、日期函数、锁与原生 SQL 都可能有差异。本节尽量使用可移植的 JPA 查询;涉及数据库行为时,在后面的测试章节会用 Testcontainers 再跑一次 PostgreSQL。
这套排查顺序有一个共同点:先找出问题发生在哪个边界,再换最小范围的策略。ORM 不神秘,它只是把对象状态、查询计划和事务生命周期映射到 SQL;把三者分开观察,绝大多数问题都有迹可循。
这一节的代码涉及查询组合、事务和并发,只靠浏览器点几下很难守住。测试也不该全部写成启动完整应用的“大而全”用例。我们按失败可能出现的边界分层,反馈会更快,定位也更准。
Repository 切片负责查询语义。除了前面的组合筛选,还应覆盖空条件、大小写、LIKE 通配符、第二页与稳定排序。一个实用的分页断言是:准备三条相同创建时间的数据,以 id 为第二排序键取两页,合并两页 id 后既没有重复也没有遗漏。这样以后有人删掉第二排序键,测试会直接指出分页不稳定。
@Test
void escapesLikeWildcardsInsteadOfTreatingThemAsPatterns() {
taskRepository.save(new Task("完成度 100%", null, 3, null));
taskRepository.save(new Task("完成度 1000", null, 3, null));
var query = new TaskQuery(null, "100%",
Service 测试负责业务状态与事务组合。状态规则至少覆盖 TODO → IN_PROGRESS → DONE 的成功路径、TODO → DONE 的拒绝路径、DONE 再次修改的拒绝路径。回滚测试必须在异常后重新从数据库读取,不能只检查手里的 Java 对象;回滚恢复的是数据库状态,不会把已修改的普通对象自动“倒带”。
Web 测试负责参数与错误契约。size=0、size=51、负页码、过长关键字、非法状态字符串、不在白名单的排序字段都应得到 400。404 与 409 要分别断言 ProblemDetail 的 title、status 和 detail,不要只检查“不是 200”。这样前端依赖的错误结构才不会在重构异常处理器时被意外改掉。
并发测试不能用“先调用一次,再调用一次”冒充同时竞争。最小验证可以开启两个独立事务,让它们都先读到同一版本,再控制提交顺序;断言一个更新成功,另一个抛出乐观锁异常,最终只有一条符合业务规则的状态历史。若测试只复用同一个事务或同一个 EntityManager,一级缓存会让场景失真。
SQL 数量也可以成为回归指标,但不要把 Hibernate 生成的完整 SQL 字符串写死。更稳妥的是统计一次列表请求的 JDBC statement 数量,断言页大小从 5 增到 20 时,关联查询数量仍然是有限批次,而不是跟着每条任务增加。SQL 的列顺序和别名可以变,N+1 不应该回来。
最后保留一条从 HTTP 到数据库的完整集成用例:创建任务,绑定负责人和标签,分页筛选到它,携带版本号改变状态,再用旧版本得到 409。它不需要覆盖所有分支,只证明 Controller、Validation、事务代理、Repository、实体映射和异常处理器能连成一条可工作的链。
这组测试各自回答不同问题:查询有没有选对行,业务动作会不会留下半成品,HTTP 契约是否稳定,并发覆盖是否被识别。分层以后,失败信息会直接指向责任区,而不是只得到一个“完整应用测试失败”。
测试数据也要尽量表达业务含义。不要只创建 task1、task2,再断言列表长度;使用“补上接口测试”“整理部署说明”这样的标题,让失败报告本身就能说明哪条记录被错误选中。时间字段使用固定日期,不在分页断言中依赖 Instant.now() 的微小先后。每个用例只准备证明该规则所需的数据,避免全局初始化器塞入几十条记录后,测试结果依赖执行顺序。数据层测试真正可靠的标志,是单独运行、整组运行和重复运行都得到相同结论。
对标签和负责人也采用同样原则:用例自己创建并保存关联对象,不假设开发初始化器已经生成某个固定编号。数据库生成的 id 只在当前测试里读取和传递。这样无论测试数据库从空表启动,还是将来切换主键生成策略,断言关注的仍是关系与业务语义,不会被一串偶然编号绑住。
现在从 HTTP 入口走一遍分页查询:
curl \
'http://localhost:8080/api/tasks?status=IN_PROGRESS&keyword=接口&assigneeId=2&tagId=3&page=0&size=5&sortBy=dueDate&direction=asc'执行链如下:
DispatcherServlet 找到 TaskController.list。参数解析器将查询字符串绑定为 TaskQuery 与分页参数,Validation 在进入方法前检查长度、页码和页大小。
Controller 调用由容器注入的 TaskService。事务代理开启只读事务,Service 将排序字段映射到白名单中的实体属性。
TaskSpecifications.from 只组合本次出现的四个条件。Repository 代理把 Specification、Pageable 交给 JPA,Hibernate 生成内容查询与 count 查询。
再走一遍状态修改:
curl \
-X PATCH 'http://localhost:8080/api/tasks/18/status' \
-H 'Content-Type: application/json' \
-d '{
"status": "DONE",
"expectedVersion": 3,
"note": "接口与回滚测试均已通过"
}'这次事务代理开启读写事务。Service 检查版本与状态迁移规则,修改托管实体,保存一条状态历史,再 flush。数据库更新条件包含旧版本号;两步都成功才提交。任何未处理的运行时异常都会让它们一起回滚。
把两条链对照起来,你会发现注解不是孤立开关:@ModelAttribute、@Valid 参与 Web 参数绑定;@Transactional 由 Service 代理在方法外管理;@ManyToOne、@ManyToMany、@Version 和审计注解由 JPA/Hibernate 在加载、flush 与生命周期回调时解释。知道“谁在什么时机读取注解”,报错时才有方向。
关联加进来后,直接返回 Task 实体的坏处更明显:
@Version 的并发语义会和普通字段更新混在一起,错误响应难以设计。DTO 把输出结构写清楚:
public record MemberSummary(Long id, String displayName) {
static MemberSummary from(Member member) {
return member == null
? null
: new MemberSummary(member.getId(), member.getDisplayName());
}
}
public record TagSummary(Long id, String name) {
static TagSummary from(Tag tag) {
return new TagSummary
标签在 DTO 中按名称排序,避免 Set 的遍历顺序让响应忽前忽后。数据库集合负责关系,API 集合负责稳定展示,两者不必共享同一个具体集合类型。
TaskHub 的主要列表条件是状态、负责人、标签和创建时间。相应索引可以从真实访问模式出发:
create index idx_tasks_status_created
on tasks(status, created_at desc);
create index idx_tasks_assignee_created
on tasks(assignee_id, created_at desc);
create index idx_task_tags_tag_task
on task_tags(tag_id, task_id);联合索引的列顺序不是装饰。status = ? order by created_at desc 与 (status, created_at) 的顺序吻合;按标签反查任务时,task_tags(tag_id, task_id) 先定位标签,再得到任务。是否命中仍要用目标数据库的执行计划确认。
标题和描述的 %关键字% 模糊搜索通常不能有效利用普通 B-tree 索引。数据量不大时先保持简单;规模上来以后,再依据所用数据库选择全文索引或专门搜索服务。此时盲目在 title 上加普通索引,未必能改善两端通配的 LIKE。
判断索引是否合适时,也不要只看“数据库里有没有这个索引”。先拿一组接近生产分布的数据执行列表查询,观察执行计划选择了哪张表作为起点、扫描了多少行、排序是否落到临时区域,再对照接口的实际耗时。比如同样是按状态查询,TODO 占全部任务九成时,状态列的区分度很低,优化器未必愿意单独使用它;把状态和创建时间组成联合索引,价值主要来自同时服务筛选与排序。反过来,如果团队几乎从不按负责人查询,提前增加负责人索引只会让每次写入多维护一份结构。索引优化不是给字段逐个盖章,而是在读取收益、写入成本和存储空间之间做选择。
还有三个常见误区:
我们这一节采用的策略很克制:动态条件用 Specification;分页由数据库完成;详情用 EntityGraph;列表关联按批次初始化;写操作用事务和版本号。每个工具都对应一个明确问题。
你可以按下面顺序验证这一节的代码:
./mvnw test
./mvnw spring-boot:run然后依次检查:
# 默认分页,确认按 createdAt desc、id asc 稳定排序
curl \
'http://localhost:8080/api/tasks?page=0&size=5'
# 组合筛选,确认条件是 AND 组合
curl \
'http://localhost:8080/api/tasks?status=IN_PROGRESS&keyword=接口&tagId=3&page=0&size=5'
# 越界页大小,应返回 400
curl -i \
'http://localhost:8080/api/tasks?page=0&size=500'
# 使用当前版本完成任务
curl -X PATCH \
'http://localhost:8080/api/tasks/18/status' \
-H 'Content-Type: application/json' \
-d '{"status":"DONE","expectedVersion":3,"note":"验证完成"}'
# 再用旧版本提交,应返回 409
curl -i -X PATCH \
'http://localhost:8080/api/tasks/18/status'
观察 SQL 日志时,重点不是复制别名,而是回答这些问题:当前页是不是在数据库里截取的?Page 是否多了一条 count?DTO 映射有没有逐项触发负责人和标签查询?状态更新的 WHERE 是否带版本号?历史插入失败时,任务更新是否回滚?
这一节让 TaskHub 的数据层从“能做 CRUD”走到了“能处理真实列表和并发写入”。列表参数经过 Controller 绑定与校验,在 Service 中转成受控的分页排序,再由 Specification 组合状态、关键字、负责人和标签条件;响应在事务内映射成稳定 DTO,不让 JPA 实体越过边界。
我们也没有回避 ORM 最容易制造错觉的部分。LAZY 不是“永远不查”,而是把查询推迟到首次访问;N+1 不是语法错误,而是一次请求悄悄变成很多次数据库往返。详情 EntityGraph、列表批量抓取、SQL 日志和针对性测试,让抓取计划可以被看见和验证。
写操作这边,@Transactional 围住状态修改与历史插入,运行时异常使它们一起回滚;@Version 把旧版本写入更新条件,让并发覆盖变成明确的 409 冲突;审计字段记录创建和修改时间。下一节加入 Thymeleaf 管理页面时,不会复制这套查询和状态规则,页面 Controller 仍然调用同一个 TaskService。这样 REST 与 HTML 只是两种入口,业务和数据边界仍只有一份。
Service 在持久化上下文仍有效时将 Task 映射为 TaskResponse。负责人和标签按当前抓取计划初始化,批量抓取避免逐行往返。
Service 将 Page 转成稳定的 TaskPageResponse 后提交只读事务。Controller 返回普通 DTO,JSON 转换器不再访问任何 JPA 实体。