Skip to content

事务与分页

Spring Data JPA 中,事务决定实体修改何时提交,分页决定列表数据如何限制范围。两者看起来简单,但很多线上问题都来自事务边界过大、懒加载失效、分页 count 慢和深分页。

如果要理解 Page 为什么会 count、Slice 为什么适合滚动加载、事务提交前 JPA 为什么会 flush,请配合阅读 Spring Data JPA 核心全过程原理Hibernate/JPA 核心全过程原理

事务边界

事务通常放在 Service 层,而不是 Controller 或 Repository。

mermaid
flowchart TD
    A["Service 方法开始"] --> B["开启事务"]
    B --> C["查询实体"]
    C --> D["修改实体字段"]
    D --> E["Hibernate 脏检查"]
    E --> F{"是否异常"}
    F -- "否" --> G["提交事务并 flush SQL"]
    F -- "是" --> H["回滚事务"]

JPA 的一个特点是:在事务内查询到的实体处于持久化状态,修改字段后不一定要显式调用 update,事务提交时 Hibernate 会通过脏检查生成 SQL。

写操作示例

java
@Transactional
public void changeUsername(Long userId, String username) {
    User user = userRepository.findById(userId)
        .orElseThrow(() -> new BizException("用户不存在"));
    user.setUsername(username);
}

这个方法没有调用 save,但如果 user 是持久化实体,事务提交时仍会更新。

readOnly

java
@Transactional(readOnly = true)
public UserDetail getUser(Long id) {
    User user = userRepository.findById(id)
        .orElseThrow(() -> new BizException("用户不存在"));
    return UserDetail.from(user);
}

readOnly = true 用来表达只读语义,并可能帮助框架做优化。但它不能替代权限控制,也不代表数据库一定禁止写入。

懒加载和事务

懒加载字段需要在持久化上下文还存在时访问。如果事务结束后再访问,可能出现 LazyInitializationException

建议:

  1. Service 层把 Entity 转成 DTO。
  2. 列表查询优先 DTO 投影。
  3. 必要时使用 fetch join 或 EntityGraph。
  4. 不要把 Entity 直接返回给 Controller。

分页流程

mermaid
flowchart TD
    A["接收 page 和 size"] --> B["校验最大 size"]
    B --> C["创建 Pageable"]
    C --> D["执行数据查询"]
    D --> E["执行 count 查询"]
    E --> F["返回 Page"]

示例:

java
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

深分页问题

传统分页:

sql
select * from users order by id desc limit 20 offset 100000;

offset 很大时,数据库仍然要跳过大量数据。优化方式:

  1. 限制最大页码。
  2. 使用基于 ID 的游标分页。
  3. 使用搜索引擎处理复杂检索。
  4. 对排序字段建立合适索引。

常见问题

问题原因建议
修改实体未保存不在事务内或实体已游离写操作加事务
懒加载异常事务外访问关联字段Service 内转 DTO
分页很慢count 或 deep offset优化 count,改 Slice 或游标分页
pageSize 过大前端无限制后端限制最大 size
事务占用太久事务内远程调用缩小事务范围

小结

JPA 事务要放在 Service 层,覆盖完整业务动作,但不要过大。分页要限制 size,关注 count SQL 和深分页。实体不要直接返回接口,避免懒加载、循环引用和字段暴露问题。

为什么分页也要关注事务

分页查询通常是只读操作,但如果在事务外直接返回 Entity,接口序列化阶段可能访问懒加载字段,导致 LazyInitializationException。更稳妥的流程是在 Service 事务内完成查询和 DTO 转换。

mermaid
flowchart TD
    A["Repository 分页查询"] --> B["Service 内转换 DTO"]
    B --> C["事务结束"]
    C --> D["Controller 返回 DTO"]

这样可以避免接口层触发额外 SQL,也能避免实体字段暴露。

继续深入:Spring Data JPA 核心全过程原理

事务边界为什么放 Service 层

Repository 只代表一次数据访问,Service 才代表一个完整业务动作。

java
@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 层事务能保证它们一起提交或一起回滚。

mermaid
flowchart TD
    A["Service 业务动作"] --> B["保存资产"]
    B --> C["保存审计日志"]
    C --> D{"是否全部成功"}
    D -- "是" --> E["提交事务"]
    D -- "否" --> F["回滚事务"]

事务不应该放太大:

  1. 不要把远程 HTTP 调用放在事务中。
  2. 不要把大文件解析全过程放在事务中。
  3. 不要把 MQ 发送直接放进本地事务里当成一体。
  4. 不要在事务里做很慢的循环查询。

事务越大,连接占用越久,锁持有越久,失败回滚成本越高。

readOnly 到底有什么用

@Transactional(readOnly = true) 表达当前方法是只读事务。它可能让框架设置 Hibernate flush 模式,减少脏检查或避免不必要 flush。

但要注意:

  1. 它不是权限控制。
  2. 它不一定让数据库强制禁止写入。
  3. 不应该在 readOnly 方法里修改实体。
  4. 如果你修改了托管实体,行为可能和 provider、事务管理器配置有关,不要依赖模糊行为。

推荐:

java
@Transactional(readOnly = true)
public AssetDetailDTO getDetail(Long id) {
    return assetRepository.findDetail(id)
        .orElseThrow(() -> new BizException("资产不存在"));
}

查询方法用 DTO,减少托管实体和懒加载风险。

flush、脏检查和事务提交

mermaid
flowchart TD
    A["事务开始"] --> B["查询托管实体"]
    B --> C["修改实体字段"]
    C --> D["flush 触发"]
    D --> E["脏检查生成 SQL"]
    E --> F["SQL 发给数据库"]
    F --> G{"方法是否正常结束"}
    G -- "是" --> H["commit"]
    G -- "否" --> I["rollback"]

重点:

  1. flush 会发 SQL。
  2. commit 会提交事务。
  3. flush 后仍然能 rollback。
  4. 查询执行前也可能触发 flush,以保证查询看到当前事务内变化。

rollbackFor 为什么重要

Spring 默认主要对 RuntimeExceptionError 回滚。受检异常默认不一定回滚。

java
@Transactional(rollbackFor = Exception.class)
public void importAssets(MultipartFile file) throws IOException {
    // 解析文件,写数据库
}

如果方法抛出 IOException,而没有配置 rollbackFor,就可能出现异常抛出但事务没有按预期回滚。

常见事务失效原因:

原因说明
同类自调用没经过 Spring 代理
异常被 catch 吞掉Spring 认为方法正常结束
受检异常未配置 rollbackFor默认不回滚
方法不是 public代理增强可能不生效
事务管理器不匹配多数据源场景常见
手动连接绕过 Spring不受事务管理

懒加载和 DTO 转换流程

错误流程:

mermaid
flowchart TD
    A["Repository 查询 Entity"] --> B["事务结束"]
    B --> C["Controller 返回 Entity"]
    C --> D["JSON 序列化访问懒加载字段"]
    D --> E["LazyInitializationException 或额外 SQL"]

正确流程:

mermaid
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 查询。

java
Page<Asset> page = assetRepository.findByStatus(status, pageable);

逻辑上等价于:

sql
select ...
from asset
where status = ?
order by created_at desc
limit ?, ?;

select count(*)
from asset
where status = ?;

复杂 JPQL 可以显式指定 count:

java
@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:

sql
select *
from asset
where status = 1
order by created_at desc
limit 20 offset 100000;

数据库仍然要跳过前 100000 行。

游标分页:

sql
select *
from asset
where status = 1
  and created_at < ?
order by created_at desc
limit 20;

JPA 中可以用自定义查询:

java
@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);

配合:

java
PageRequest.of(0, 20)

商业场景:资产导入事务设计

错误:十万条资产放一个事务。

问题:

  1. 持久化上下文保存大量实体,内存压力大。
  2. 事务持锁时间长。
  3. 失败回滚成本高。
  4. 数据库日志压力大。

更合理:

  1. 文件解析和校验在事务外。
  2. 分批写入,每批一个事务。
  3. 每批控制 300 到 1000 条,压测决定。
  4. JPA 批量导入定期 flush/clear
  5. 特别大的导入可用 MyBatis/JDBC batch 或数据库导入工具。

面试标准回答

text
Spring Data JPA 事务通常放在 Service 层,覆盖一个完整业务动作,而不是 Controller 或 Repository。事务内查询到的实体是托管态,修改字段后提交前 flush 会触发脏检查并生成 SQL;flush 是同步 SQL,commit 是提交事务。readOnly 表达只读语义但不是权限控制,也不能依赖它绝对禁止写入。分页时 Page 通常执行数据查询和 count 查询,大表 count 可能很慢,不需要总数时可用 Slice。接口层不要直接返回 Entity,应在 Service 内转 DTO,避免懒加载异常、循环引用和隐式 SQL。