事务与分页
Spring Data JPA 中,事务决定实体修改何时提交,分页决定列表数据如何限制范围。两者看起来简单,但很多线上问题都来自事务边界过大、懒加载失效、分页 count 慢和深分页。
如果要理解 Page 为什么会 count、Slice 为什么适合滚动加载、事务提交前 JPA 为什么会 flush,请配合阅读 Spring Data JPA 核心全过程原理 和 Hibernate/JPA 核心全过程原理。
事务边界
事务通常放在 Service 层,而不是 Controller 或 Repository。
flowchart TD
A["Service 方法开始"] --> B["开启事务"]
B --> C["查询实体"]
C --> D["修改实体字段"]
D --> E["Hibernate 脏检查"]
E --> F{"是否异常"}
F -- "否" --> G["提交事务并 flush SQL"]
F -- "是" --> H["回滚事务"]JPA 的一个特点是:在事务内查询到的实体处于持久化状态,修改字段后不一定要显式调用 update,事务提交时 Hibernate 会通过脏检查生成 SQL。
写操作示例
@Transactional
public void changeUsername(Long userId, String username) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BizException("用户不存在"));
user.setUsername(username);
}这个方法没有调用 save,但如果 user 是持久化实体,事务提交时仍会更新。
readOnly
@Transactional(readOnly = true)
public UserDetail getUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new BizException("用户不存在"));
return UserDetail.from(user);
}readOnly = true 用来表达只读语义,并可能帮助框架做优化。但它不能替代权限控制,也不代表数据库一定禁止写入。
懒加载和事务
懒加载字段需要在持久化上下文还存在时访问。如果事务结束后再访问,可能出现 LazyInitializationException。
建议:
- Service 层把 Entity 转成 DTO。
- 列表查询优先 DTO 投影。
- 必要时使用 fetch join 或 EntityGraph。
- 不要把 Entity 直接返回给 Controller。
分页流程
flowchart TD
A["接收 page 和 size"] --> B["校验最大 size"]
B --> C["创建 Pageable"]
C --> D["执行数据查询"]
D --> E["执行 count 查询"]
E --> F["返回 Page"]示例:
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<User> result = userRepository.findByStatus(status, pageable);Page 和 Slice
| 类型 | 是否查询总数 | 适合场景 |
|---|---|---|
Page | 是 | 后台管理分页,需要总页数 |
Slice | 否 | 信息流,只关心是否有下一页 |
大表分页中,count 查询可能非常慢。如果业务不需要总数,优先考虑 Slice。
深分页问题
传统分页:
select * from users order by id desc limit 20 offset 100000;offset 很大时,数据库仍然要跳过大量数据。优化方式:
- 限制最大页码。
- 使用基于 ID 的游标分页。
- 使用搜索引擎处理复杂检索。
- 对排序字段建立合适索引。
常见问题
| 问题 | 原因 | 建议 |
|---|---|---|
| 修改实体未保存 | 不在事务内或实体已游离 | 写操作加事务 |
| 懒加载异常 | 事务外访问关联字段 | Service 内转 DTO |
| 分页很慢 | count 或 deep offset | 优化 count,改 Slice 或游标分页 |
| pageSize 过大 | 前端无限制 | 后端限制最大 size |
| 事务占用太久 | 事务内远程调用 | 缩小事务范围 |
小结
JPA 事务要放在 Service 层,覆盖完整业务动作,但不要过大。分页要限制 size,关注 count SQL 和深分页。实体不要直接返回接口,避免懒加载、循环引用和字段暴露问题。
为什么分页也要关注事务
分页查询通常是只读操作,但如果在事务外直接返回 Entity,接口序列化阶段可能访问懒加载字段,导致 LazyInitializationException。更稳妥的流程是在 Service 事务内完成查询和 DTO 转换。
flowchart TD
A["Repository 分页查询"] --> B["Service 内转换 DTO"]
B --> C["事务结束"]
C --> D["Controller 返回 DTO"]这样可以避免接口层触发额外 SQL,也能避免实体字段暴露。
继续深入:Spring Data JPA 核心全过程原理。
事务边界为什么放 Service 层
Repository 只代表一次数据访问,Service 才代表一个完整业务动作。
@Transactional(rollbackFor = Exception.class)
public void createAsset(AssetCreateCommand command) {
Asset asset = new Asset(command.assetCode(), command.assetName());
assetRepository.save(asset);
assetAuditRepository.save(AssetAudit.create(asset.getAssetCode(), "CREATE"));
}如果两个 save 各自提交,可能出现资产写入成功,但审计日志失败。Service 层事务能保证它们一起提交或一起回滚。
flowchart TD
A["Service 业务动作"] --> B["保存资产"]
B --> C["保存审计日志"]
C --> D{"是否全部成功"}
D -- "是" --> E["提交事务"]
D -- "否" --> F["回滚事务"]事务不应该放太大:
- 不要把远程 HTTP 调用放在事务中。
- 不要把大文件解析全过程放在事务中。
- 不要把 MQ 发送直接放进本地事务里当成一体。
- 不要在事务里做很慢的循环查询。
事务越大,连接占用越久,锁持有越久,失败回滚成本越高。
readOnly 到底有什么用
@Transactional(readOnly = true) 表达当前方法是只读事务。它可能让框架设置 Hibernate flush 模式,减少脏检查或避免不必要 flush。
但要注意:
- 它不是权限控制。
- 它不一定让数据库强制禁止写入。
- 不应该在 readOnly 方法里修改实体。
- 如果你修改了托管实体,行为可能和 provider、事务管理器配置有关,不要依赖模糊行为。
推荐:
@Transactional(readOnly = true)
public AssetDetailDTO getDetail(Long id) {
return assetRepository.findDetail(id)
.orElseThrow(() -> new BizException("资产不存在"));
}查询方法用 DTO,减少托管实体和懒加载风险。
flush、脏检查和事务提交
flowchart TD
A["事务开始"] --> B["查询托管实体"]
B --> C["修改实体字段"]
C --> D["flush 触发"]
D --> E["脏检查生成 SQL"]
E --> F["SQL 发给数据库"]
F --> G{"方法是否正常结束"}
G -- "是" --> H["commit"]
G -- "否" --> I["rollback"]重点:
- flush 会发 SQL。
- commit 会提交事务。
- flush 后仍然能 rollback。
- 查询执行前也可能触发 flush,以保证查询看到当前事务内变化。
rollbackFor 为什么重要
Spring 默认主要对 RuntimeException 和 Error 回滚。受检异常默认不一定回滚。
@Transactional(rollbackFor = Exception.class)
public void importAssets(MultipartFile file) throws IOException {
// 解析文件,写数据库
}如果方法抛出 IOException,而没有配置 rollbackFor,就可能出现异常抛出但事务没有按预期回滚。
常见事务失效原因:
| 原因 | 说明 |
|---|---|
| 同类自调用 | 没经过 Spring 代理 |
| 异常被 catch 吞掉 | Spring 认为方法正常结束 |
| 受检异常未配置 rollbackFor | 默认不回滚 |
| 方法不是 public | 代理增强可能不生效 |
| 事务管理器不匹配 | 多数据源场景常见 |
| 手动连接绕过 Spring | 不受事务管理 |
懒加载和 DTO 转换流程
错误流程:
flowchart TD
A["Repository 查询 Entity"] --> B["事务结束"]
B --> C["Controller 返回 Entity"]
C --> D["JSON 序列化访问懒加载字段"]
D --> E["LazyInitializationException 或额外 SQL"]正确流程:
flowchart TD
A["Service 开启只读事务"] --> B["Repository 查询"]
B --> C["事务内组装 DTO"]
C --> D["事务结束"]
D --> E["Controller 返回 DTO"]DTO 是接口契约,不是持久化对象。它能明确返回字段,避免实体泄露和懒加载问题。
Page 的 count SQL 怎么来的
当 Repository 返回 Page<T> 时,Spring Data JPA 需要知道总条数,所以通常生成 count 查询。
Page<Asset> page = assetRepository.findByStatus(status, pageable);逻辑上等价于:
select ...
from asset
where status = ?
order by created_at desc
limit ?, ?;
select count(*)
from asset
where status = ?;复杂 JPQL 可以显式指定 count:
@Query(
value = """
select new com.demo.AssetListItem(a.id, a.assetCode, d.deptName)
from Asset a
join a.department d
where a.status = :status
""",
countQuery = """
select count(a.id)
from Asset a
where a.status = :status
"""
)
Page<AssetListItem> pageItems(@Param("status") Integer status, Pageable pageable);如果 count 不需要 join,就不要让自动 count 背上复杂 join。
Slice 为什么适合滚动加载
Slice 不关心总条数,只关心有没有下一页。实现上通常多查一条判断是否还有更多。
| 类型 | 返回信息 | SQL 成本 |
|---|---|---|
Page | 当前页、总条数、总页数 | 数据查询 + count |
Slice | 当前页、是否有下一页 | 通常不查 count |
信息流、日志列表、采集记录滚动加载通常不需要总页数,使用 Slice 更合适。
深分页怎么优化
传统 offset:
select *
from asset
where status = 1
order by created_at desc
limit 20 offset 100000;数据库仍然要跳过前 100000 行。
游标分页:
select *
from asset
where status = 1
and created_at < ?
order by created_at desc
limit 20;JPA 中可以用自定义查询:
@Query("""
select new com.demo.AssetListItem(a.id, a.assetCode, a.createdAt)
from Asset a
where a.status = :status
and a.createdAt < :lastCreatedAt
order by a.createdAt desc
""")
List<AssetListItem> nextPage(@Param("status") Integer status,
@Param("lastCreatedAt") LocalDateTime lastCreatedAt,
Pageable pageable);配合:
PageRequest.of(0, 20)商业场景:资产导入事务设计
错误:十万条资产放一个事务。
问题:
- 持久化上下文保存大量实体,内存压力大。
- 事务持锁时间长。
- 失败回滚成本高。
- 数据库日志压力大。
更合理:
- 文件解析和校验在事务外。
- 分批写入,每批一个事务。
- 每批控制 300 到 1000 条,压测决定。
- JPA 批量导入定期
flush/clear。 - 特别大的导入可用 MyBatis/JDBC batch 或数据库导入工具。
面试标准回答
Spring Data JPA 事务通常放在 Service 层,覆盖一个完整业务动作,而不是 Controller 或 Repository。事务内查询到的实体是托管态,修改字段后提交前 flush 会触发脏检查并生成 SQL;flush 是同步 SQL,commit 是提交事务。readOnly 表达只读语义但不是权限控制,也不能依赖它绝对禁止写入。分页时 Page 通常执行数据查询和 count 查询,大表 count 可能很慢,不需要总数时可用 Slice。接口层不要直接返回 Entity,应在 Service 内转 DTO,避免懒加载异常、循环引用和隐式 SQL。