Java 基础体系 · 第 28/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Spring Data 数据访问:Repository、事务、分页、审计和缓存边界

Spring Data 不是一种数据库,也不是单独的 ORM。它是一组统一的数据访问抽象,Spring Data Commons 提供 Repository、分页、排序等通用模型,Spring Data JPA、Spring Data JDBC、Spring Data MongoDB 等模块分别把这些模型映射到具体的数据存储。

因此,下面几个概念必须先分开:

  • Repository:应用访问持久化数据的接口抽象。
  • 事务:一组数据操作的原子性、隔离性和提交边界。
  • 分页:把有序结果集切分为有限窗口。
  • 审计:记录谁、何时创建或修改了数据。
  • 缓存边界:规定哪些数据可以复用、何时失效,以及缓存和数据库的一致性如何处理。

这些概念可以组合使用,但它们不是同一层的能力:

Controller
   │
   ▼
Application Service
   │  事务边界、权限、业务规则、缓存边界
   ▼
Repository
   │  查询定义、持久化抽象
   ▼
JPA/Hibernate / JDBC / MongoDB Driver
   │
   ▼
Database

例如,Repository 可以执行查询,但不应自动承担“一个订单是否允许取消”的业务判断;分页可以限制返回行数,但不能自动保证跨页期间的数据快照一致;审计可以记录修改者,但不能替代完整的变更历史;缓存可以减少数据库访问,但不能天然保证读到最新数据。


一、Repository:隐藏访问方式,但不隐藏数据语义

1. Repository 是什么

在 Spring Data 中,Repository 通常是一个接口:

public interface UserRepository extends Repository<User, Long> {

    Optional<User> findById(Long id);

    List<User> findByStatus(UserStatus status);

    User save(User user);
}

接口本身没有实现类。Spring Data 在启动时扫描该接口,为它生成代理对象。调用方法时,代理根据方法签名、注解和具体模块实现访问数据库。

Repository<T, ID> 中:

  • T 是聚合根或实体类型;
  • ID 是标识符类型。

常用的父接口包括:

public interface UserRepository
        extends JpaRepository<User, Long> {
}

JpaRepository 继承关系提供了:

  • 基本 CRUD;
  • flush()saveAndFlush()
  • PageableSort 支持;
  • 批量删除等 JPA 相关能力。

但继承更大的接口并不意味着应用自动获得正确的业务语义。比如:

userRepository.deleteAll();

可能删除整张表中的数据。Repository API 是能力集合,不是业务授权系统。

2. 方法名查询的解析过程

Spring Data 可以根据方法名生成查询:

List<User> findByStatusAndCreatedAtAfterOrderByCreatedAtDesc(
        UserStatus status,
        Instant time
);

它大致被解析为:

where status = ?
  and created_at > ?
order by created_at desc

常见关键字包括:

  • AndOr
  • Between
  • LessThanGreaterThanEqual
  • IsNull
  • LikeContaining
  • In
  • OrderBy

但是方法名查询存在两个边界。

第一,方法名越长,查询语义越难审查:

List<Order> findByUserIdAndStatusAndCreatedAtBetweenAndAmountGreaterThanOrderByCreatedAtDesc(
        Long userId,
        OrderStatus status,
        Instant start,
        Instant end,
        BigDecimal amount
);

第二,方法名表达不了所有 SQL 语义,例如复杂聚合、窗口函数、数据库特定语法、精确的 fetch 策略。这时应使用 @Query、Specification、Querydsl 或自定义实现,而不是强行继续拼方法名。

@Query("""
       select u
       from User u
       where u.status = :status
         and u.createdAt >= :from
       order by u.createdAt desc, u.id desc
       """)
List<User> findRecentUsers(
        @Param("status") UserStatus status,
        @Param("from") Instant from
);

JPQL 查询的是实体和属性,不是数据库表名和列名。上面的 User 是实体名,createdAt 是实体属性;Hibernate 再将其翻译为具体 SQL。

3. save 不等于立即执行 INSERTUPDATE

以 Spring Data JPA 为例,save 的核心行为可以抽象为:

if (entityInformation.isNew(entity)) {
    entityManager.persist(entity);
    return entity;
} else {
    return entityManager.merge(entity);
}

这里的关键不是方法名,而是实体的新旧判断以及 JPA 持久化上下文的状态。

一个典型生命周期是:

new
 │ persist
 ▼
managed ── flush ──> SQL 执行
 │
 │ clear / transaction end
 ▼
detached

删除后则进入 removed 状态:

managed ── remove ──> removed

persist 的情况

User user = new User();
user.setName("Alice");

User returned = repository.save(user);

如果 user 被判断为新实体,JPA 通常将它变成 managed。SQL 可能在以下时机执行:

  • 显式 flush()
  • 事务提交前自动 flush;
  • 某些查询触发 flush;
  • 数据库约束需要提前验证时。

所以 save() 返回成功,不代表数据库已经完成提交。

merge 的情况

User detached = loadOutsideCurrentContext();
detached.setName("Bob");

User managed = repository.save(detached);

merge 的重要语义是:复制 detached 对象的状态到一个 managed 实例,并返回这个 managed 实例。传入的 detached 对象本身不因此变成 managed。

因此应优先使用返回值:

User managed = userRepository.save(detached);

而不要假定:

userRepository.save(detached);
detached.getSomeLazyAssociation(); // 不应依赖 detached 已经受管理

实体是否为新对象的判断,常见依据是非原始类型的 @Id 是否为 null,但具体判断还受到 Persistable、版本字段等因素影响。使用手工分配 ID 时,不能简单假设“ID 非空就是旧实体”。

4. Repository 不应该替代 Service 层事务边界

下面的操作通常属于一个业务事务:

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    Account from = accountRepository.findById(fromId)
            .orElseThrow();
    Account to = accountRepository.findById(toId)
            .orElseThrow();

    from.withdraw(amount);
    to.deposit(amount);
}

如果只把两个 save 分散在两个 Repository 方法中,可能出现:

扣款成功
提交
入账失败

这不是 Repository 的错误,而是事务边界放错了。Repository 描述“如何访问数据”,Service 负责“哪些访问必须作为一个业务操作提交”。


二、事务:数据库操作何时成为事实

1. @Transactional 的实际工作方式

Spring 的声明式事务通常基于代理:

调用方
  │
  ▼
@Transactional 代理
  ├─ 开启事务
  ├─ 调用目标方法
  ├─ 正常返回:提交
  └─ 异常退出:按规则回滚

因此下面的调用可以触发事务:

orderService.createOrder(command);

但同一个类中的自调用通常不会经过代理:

@Service
public class OrderService {

    public void outer() {
        inner(); // 直接调用,不经过 Spring 代理
    }

    @Transactional
    public void inner() {
        // 不能依赖这里一定创建了事务
    }
}

常见修复方式是:

  • 将事务方法移动到另一个 Spring Bean;
  • 从外部通过代理调用;
  • 不使用自调用作为事务设计基础。

2. 默认回滚规则

Spring 默认对运行时异常和错误回滚,对受检异常不自动回滚:

@Transactional
public void process() throws IOException {
    // IOException 默认不一定触发回滚
}

如果业务要求受检异常也回滚:

@Transactional(rollbackFor = IOException.class)
public void process() throws IOException {
}

这只是 Spring 的事务拦截规则。数据库自身的约束错误、连接断开、死锁等异常,还会经过 JDBC 驱动、JPA 提供商和事务管理器转换后再传播,不能仅凭异常类名猜测所有行为。

3. 事务属性的含义

常见属性如下:

@Transactional(
        propagation = Propagation.REQUIRED,
        isolation = Isolation.READ_COMMITTED,
        timeout = 5,
        readOnly = true
)

propagation

REQUIRED 是默认值:

  • 外层有事务:加入外层事务;
  • 外层没有事务:创建新事务。

REQUIRES_NEW 会挂起外层事务并创建新事务。它常用于独立写入审计或通知记录,但有一个真实风险:外层事务持有数据库连接,内层新事务还需要连接,连接池过小可能造成等待甚至死锁。

NESTED 是否可用取决于事务管理器和底层数据库是否支持保存点,不能把它当成所有 JPA 环境都可用的“子事务”。

isolation

隔离级别控制并发事务之间能看到什么。以常见异常为例:

  • 脏读:读到别的事务尚未提交的数据;
  • 不可重复读:同一事务两次读取同一行得到不同值;
  • 幻读:同一条件两次查询,结果行集合发生变化。

实际效果取决于数据库默认隔离级别、MVCC 实现和锁策略。设置 @Transactional(isolation = ...) 不代表数据库一定能以完全相同的方式实现该语义。

readOnly

readOnly = true 是事务提示,不是数据库级只读安全阀。对 Spring Data JPA,Repository 的读取方法通常带有只读事务语义,具体实现可能调整 Hibernate flush 行为;但它不应被理解为“任何写操作都会被编译器拒绝”。

如果在只读事务中修改实体,可能出现:

  • 修改未被 flush;
  • 修改被 flush;
  • 数据库拒绝写入;
  • 行为取决于 JPA 提供商和配置。

真正需要禁止写入时,应在数据库权限或应用架构层面落实。

4. 一个完整的事务算例

假设账户初始余额:

A = 100
B = 50
转账金额 = 30

事务执行:

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    Account from = accountRepository.findById(fromId)
            .orElseThrow();
    Account to = accountRepository.findById(toId)
            .orElseThrow();

    from.withdraw(amount); // A = 70
    to.deposit(amount);   // B = 80
}

在事务提交前,数据库可能尚未执行最终 UPDATE。逻辑状态是:

持久化上下文:
A = 70
B = 80

数据库:
A = 100
B = 50

flush 后,数据库连接上可能执行:

update account set balance = 70 where id = ?;
update account set balance = 80 where id = ?;

提交成功后:

数据库:
A = 70
B = 80

如果第二次更新因余额约束、死锁或连接异常失败,事务管理器回滚第一条更新,最终仍为:

A = 100
B = 50

这就是原子性。若两个更新分别位于两个事务中,则这个保证不存在。

5. 乐观锁是事务并发控制的一部分

实体可以使用版本字段:

@Entity
public class Account {

    @Id
    @GeneratedValue
    private Long id;

    @Version
    private long version;

    @Column(nullable = false)
    private BigDecimal balance;

    public void withdraw(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) {
            throw new IllegalStateException("余额不足");
        }
        balance = balance.subtract(amount);
    }
}

初始状态:

balance = 100, version = 7

事务 T1 和 T2 都读到版本 7。

T1 更新时,逻辑 SQL 类似:

update account
set balance = 70, version = 8
where id = ? and version = 7;

影响行数为 1,成功。

T2 仍尝试:

update account
set balance = 80, version = 8
where id = ? and version = 7;

影响行数为 0,Hibernate 通常抛出乐观锁异常。T2 不能静默覆盖 T1 的修改。

这解决的是“丢失更新”,不是所有并发业务问题。例如“账户余额不能为负”还需要在同一事务中执行校验和更新,或者使用数据库锁、原子 SQL 等策略。


三、查询、实体关联与事务边界

1. Lazy 加载为什么会失败

LAZY 表示关联数据可以延迟读取,并不表示“永远不会查询”。

@Entity
public class Order {

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private User user;
}

如果实体仍处于持久化上下文管理中,访问:

order.getUser().getName();

Hibernate 可能发起额外 SQL。如果实体已经 detached,访问未初始化的代理可能抛出 LazyInitializationException

因此,应用服务应在事务内把需要的读取完成,并返回 DTO:

@Transactional(readOnly = true)
public OrderDetail getDetail(Long orderId) {
    Order order = orderRepository.findDetailById(orderId)
            .orElseThrow();

    return new OrderDetail(
            order.getId(),
            order.getUser().getName()
    );
}

查询可以使用明确的 fetch join:

@Query("""
       select o
       from Order o
       join fetch o.user
       where o.id = :id
       """)
Optional<Order> findDetailById(@Param("id") Long id);

2. N+1 与 Repository 方法的关系

下面的代码可能产生 N+1:

List<Order> orders = orderRepository.findAll();

for (Order order : orders) {
    System.out.println(order.getUser().getName());
}

可能的 SQL 路径:

1 次:查询所有订单
N 次:分别查询每个订单的用户
总次数:1 + N

解决方案不是盲目把所有关联都改成 eager,而是根据用例选择:

  • fetch join;
  • @EntityGraph
  • 批量抓取;
  • DTO 投影;
  • 分批查询;
  • 明确的关联读取事务。

对分页查询尤其要谨慎:对一对多集合做 fetch join 可能导致行膨胀,Hibernate 需要去重,数据库分页可能失真或被转移到内存处理。分页列表通常更适合先分页查询根实体,再按 ID 批量加载关联,或者直接使用 DTO 查询。


四、分页:有限窗口不是稳定快照

1. Offset 分页的定义

Spring Data 使用 Pageable 描述页码、大小和排序:

Pageable pageable = PageRequest.of(
        0,                                      // 第 0 页
        20,                                     // 每页 20 行
        Sort.by(
                Sort.Order.desc("createdAt"),
                Sort.Order.desc("id")
        )
);

Page<Order> page = orderRepository.findByStatus(
        OrderStatus.PAID,
        pageable
);

Repository:

public interface OrderRepository
        extends JpaRepository<Order, Long> {

    Page<Order> findByStatus(
            OrderStatus status,
            Pageable pageable
    );
}

Page 通常包含:

  • 当前页内容;
  • 页码;
  • 页大小;
  • 总元素数;
  • 总页数;
  • 是否有上一页、下一页。

为了得到总数,框架通常需要执行两类 SQL:

select ...
from orders
where status = ?
order by created_at desc, id desc
limit 20 offset 0;

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

因此,Page<T> 的代价不仅是内容查询,还可能包含 count 查询。

Slice<T> 只关心是否还有下一页,通常通过多取一条记录实现:

Slice<Order> findByStatus(
        OrderStatus status,
        Pageable pageable
);

如果请求 20 条,查询可以实际取 21 条:

返回 21 条:取前 20 条,hasNext = true
返回不超过 20 条:hasNext = false

它避免了总数统计,适合“加载更多”场景。

2. 为什么排序必须稳定

只按创建时间排序:

Sort.by(Sort.Order.desc("createdAt"))

如果多行 createdAt 相同,数据库可以以任意顺序返回这些并列行。于是同一批数据可能在相邻请求中发生重复或遗漏。

应加入唯一的次排序键:

Sort.by(
        Sort.Order.desc("createdAt"),
        Sort.Order.desc("id")
)

其逻辑顺序是:

(createdAt 降序, id 降序)

只要 (createdAt, id) 组合唯一,分页顺序就有确定性。

3. Offset 分页的并发反例

假设按 createdAt desc 排序。

第一次请求:

第 1 页:A, B, C

在第二次请求前插入一条新数据 X:

X, A, B, C

第二页使用 offset = 3,返回:

C, D, E

C 被第一页和第二页重复返回。

如果第一页之后删除了 A,第二页仍使用 offset = 3,则原本应该出现的某一条记录可能被跳过。

因此 offset 分页表达的是:

在本次查询时,跳过前 N 条符合条件的结果。

它不表达:

从上一次结果之后继续读取同一个不可变快照。

若需要稳定的连续翻页,可以使用基于游标的 keyset 分页。

4. Keyset 分页的推导

按:

createdAt desc, id desc

排序,上一页最后一条是:

lastCreatedAt = 2025-01-10T12:00:00Z
lastId = 100

下一页必须满足“排在它之后”:

where
      created_at < :lastCreatedAt
   or (created_at = :lastCreatedAt and id < :lastId)
order by created_at desc, id desc
limit :size;

对于升序则相反:

where
      created_at > :lastCreatedAt
   or (created_at = :lastCreatedAt and id > :lastId)

Repository 可以使用原生查询表达:

@Query(value = """
       select *
       from orders
       where status = :status
         and (
              created_at < :createdAt
              or (created_at = :createdAt and id < :id)
         )
       order by created_at desc, id desc
       limit :limit
       """, nativeQuery = true)
List<Order> findNextPage(
        @Param("status") String status,
        @Param("createdAt") Instant createdAt,
        @Param("id") Long id,
        @Param("limit") int limit
);

Keyset 的成立条件是:

  1. 排序字段的组合形成可比较的顺序;
  2. 游标包含所有排序键;
  3. 下一页使用与排序方向一致的比较符;
  4. 过滤条件在前后请求间具有可接受的稳定性。

它仍然不是数据库快照。如果之前的记录被修改,排序位置仍可能变化;但对只追加、按时间顺序读取的场景,通常比大 offset 更稳定,也更容易利用索引。

5. 分页与关联查询的边界

以下查询存在风险:

@Query("""
       select distinct o
       from Order o
       join fetch o.items
       where o.status = :status
       """)
Page<Order> findPageWithItems(
        @Param("status") OrderStatus status,
        Pageable pageable
);

一个订单有 5 个明细,SQL 行可能变成 5 行;数据库的 limit 20 offset 0 作用于行,而不是去重后的订单实体。最终可能只得到少于 20 个订单,count 查询也可能需要特殊处理。

更可靠的方案之一是两步查询:

@Query("""
       select o.id
       from Order o
       where o.status = :status
       order by o.createdAt desc, o.id desc
       """)
Page<Long> findPageIds(
        @Param("status") OrderStatus status,
        Pageable pageable
);

然后:

List<Long> ids = idPage.getContent();
List<Order> orders = ids.isEmpty()
        ? List.of()
        : orderRepository.findAllWithItemsByIdIn(ids);

第二次查询还需要把结果按照第一页 ID 的顺序重新排列,因为 IN (...) 本身不保证顺序。


五、审计:记录创建和修改元数据,而不是记录完整历史

1. 审计解决什么问题

Spring Data auditing 通常记录:

  • 创建时间;
  • 最后修改时间;
  • 创建者;
  • 最后修改者。

它不等价于:

  • 业务事件;
  • 字段级变更历史;
  • 删除历史;
  • 合规不可抵赖日志。

一个实体可以这样定义:

@Entity
@EntityListeners(AuditingEntityListener.class)
public class Document {

    @Id
    @GeneratedValue
    private Long id;

    private String title;

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private Instant modifiedAt;

    @CreatedBy
    @Column(nullable = false, updatable = false)
    private String createdBy;

    @LastModifiedBy
    @Column(nullable = false)
    private String modifiedBy;

    // getter/setter
}

启用审计:

@Configuration
@EnableJpaAuditing
public class AuditingConfig {

    @Bean
    AuditorAware<String> auditorAware() {
        return () -> Optional.ofNullable(
                SecurityContextHolder.getContext()
                        .getAuthentication()
        )
        .filter(Authentication::isAuthenticated)
        .map(Authentication::getName);
    }
}

这里的 AuditorAware<String> 返回当前操作者的标识。后台任务、消息消费者和匿名请求必须定义清楚身份来源,否则可能得到空值、系统用户或错误地复用线程上下文。

2. 审计字段的生命周期

一次创建过程可以抽象为:

new entity
  │
  ▼
持久化生命周期回调
  ├─ 设置 createdAt
  ├─ 设置 modifiedAt
  ├─ 设置 createdBy
  └─ 设置 modifiedBy
  │
  ▼
flush
  │
  ▼
INSERT

更新过程则通常更新:

modifiedAt
modifiedBy

具体回调时机由 Spring Data 审计组件和持久化实现共同决定。审计值在 flush 前被写入实体状态,最终随 INSERT 或 UPDATE 持久化。

3. 哪些操作会绕过审计

批量 JPQL 更新:

@Modifying
@Query("""
       update Document d
       set d.title = :title
       where d.id = :id
       """)
int updateTitle(
        @Param("id") Long id,
        @Param("title") String title
);

这类语句直接在数据库执行,不会逐个加载实体,因此通常不会触发实体监听器,也不会自动填充 @LastModifiedDate@LastModifiedBy

需要显式写入:

@Modifying
@Query("""
       update Document d
       set d.title = :title,
           d.modifiedAt = :modifiedAt,
           d.modifiedBy = :modifiedBy
       where d.id = :id
       """)
int updateTitleWithAudit(
        @Param("id") Long id,
        @Param("title") String title,
        @Param("modifiedAt") Instant modifiedAt,
        @Param("modifiedBy") String modifiedBy
);

执行批量更新后,当前 EntityManager 中可能仍持有旧实体。应根据场景清理或刷新持久化上下文:

@Modifying(clearAutomatically = true, flushAutomatically = true)

但自动清理会使尚未保存的实体修改丢失,因此不能机械添加。

如果需要完整变更历史,应使用单独的审计表、事件日志、Hibernate Envers 或业务事件。简单的“最后修改者”字段无法回答:

谁在什么时候把状态从 PAID 改成 REFUNDED?
中间改过几次?
修改前后的值分别是什么?

六、缓存:缓存的是读路径,不是数据库事实

1. Spring Cache 抽象的组件关系

Spring Cache 主要由三部分组成:

@Cacheable / @CachePut / @CacheEvict
              │
              ▼
        CacheInterceptor
              │
              ▼
          CacheManager
              │
              ▼
 Redis / Caffeine / ConcurrentMap ...

示例配置:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    CacheManager cacheManager() {
        return new CaffeineCacheManager("userById");
    }
}

服务方法:

@Service
public class UserQueryService {

    private final UserRepository userRepository;

    public UserQueryService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Cacheable(
            cacheNames = "userById",
            key = "#id",
            unless = "#result == null"
    )
    @Transactional(readOnly = true)
    public UserView findById(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow();
        return new UserView(user.getId(), user.getName());
    }
}

第一次调用:

调用 findById(1)
  ├─ 缓存未命中
  ├─ 查询数据库
  ├─ 返回 UserView
  └─ 写入 key = 1

第二次调用:

调用 findById(1)
  ├─ 缓存命中
  └─ 不访问数据库

缓存抽象只定义注解和调用语义,不统一规定底层缓存的:

  • 持久化方式;
  • 淘汰算法;
  • 序列化格式;
  • TTL;
  • 分布式一致性;
  • 故障恢复行为。

这些由具体 CacheManager 和缓存产品决定。

2. 为什么缓存 DTO 通常比缓存实体安全

缓存 JPA 实体会引入状态问题:

  • 实体可能携带 Hibernate 代理;
  • 关联字段可能在缓存外部访问时触发懒加载;
  • 实体版本可能过期;
  • 序列化可能暴露内部字段;
  • 从缓存取出的对象不是当前 EntityManager 管理的 managed 实例。

因此更适合缓存不可变或近似不可变的读模型:

public record UserView(
        Long id,
        String name
) {
}

缓存边界最好放在查询服务,而不是把实体管理状态扩散到缓存系统。

3. 缓存键必须完整表达查询条件

错误示例:

@Cacheable(cacheNames = "orders")
public List<OrderView> findByUser(Long userId, OrderStatus status) {
    // key 默认可能包含全部参数,具体应明确设计
}

生产代码应显式表达业务键:

@Cacheable(
        cacheNames = "ordersByUserAndStatus",
        key = "{#userId, #status.name()}"
)
public List<OrderView> findByUser(
        Long userId,
        OrderStatus status
) {
}

如果还存在租户、权限范围、语言或数据版本,缓存键必须包含这些维度。否则可能出现:

租户 A 的数据
被租户 B 使用相同 key 读到

这不是普通缓存 miss,而是数据隔离故障。

4. 写数据库后什么时候失效

最简单的写入方法:

@CacheEvict(
        cacheNames = "userById",
        key = "#command.id"
)
@Transactional
public void renameUser(RenameUserCommand command) {
    User user = userRepository.findById(command.id())
            .orElseThrow();

    user.rename(command.name());
}

这里有一个事务边界问题:缓存清除和数据库提交不是天然的同一原子操作。

可能的失败路径:

1. 数据库事务开始
2. 修改实体
3. 方法返回
4. 缓存被清除
5. 数据库提交失败

结果是数据库仍是旧值,但缓存已经被清除。下一次读取只会重新查库,通常还能恢复,但会增加访问;更危险的是反过来:

1. 数据库修改已经提交
2. 缓存清除失败
3. 旧值继续被返回

因此需要先明确接受哪种一致性:

  • 允许短时间旧读:使用 TTL 和失效;
  • 要求提交后再失效:使用事务同步或 @TransactionalEventListener(phase = AFTER_COMMIT)
  • 多实例严格协调:使用可靠消息、Outbox、版本号或集中式缓存失效机制;
  • 强一致读:关键路径绕过缓存,直接读取数据库。

一个提交后发布事件的示例:

public record UserChangedEvent(Long userId) {
}
@Service
public class UserCommandService {

    private final UserRepository repository;
    private final ApplicationEventPublisher publisher;

    public UserCommandService(
            UserRepository repository,
            ApplicationEventPublisher publisher
    ) {
        this.repository = repository;
        this.publisher = publisher;
    }

    @Transactional
    public void rename(Long id, String name) {
        User user = repository.findById(id).orElseThrow();
        user.rename(name);

        publisher.publishEvent(new UserChangedEvent(id));
    }
}
@Component
public class UserCacheInvalidator {

    private final CacheManager cacheManager;

    public UserCacheInvalidator(CacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onUserChanged(UserChangedEvent event) {
        Cache cache = cacheManager.getCache("userById");
        if (cache != null) {
            cache.evict(event.userId());
        }
    }
}

这个方案的语义是:事务成功提交后才执行失效。但它仍不是分布式可靠消息;进程可能在数据库提交成功和事件监听器执行之间崩溃。若不能接受这个窗口,应将变更事件写入 Outbox 表,并由可靠投递机制处理。

5. @Cacheable 的代理边界和并发击穿

自调用同样不会经过缓存代理:

public UserView outer(Long id) {
    return findById(id); // 不一定触发 @Cacheable
}

@Cacheable("userById")
public UserView findById(Long id) {
}

另外,多个线程在同一时间发现缓存 miss,可能同时查询数据库:

线程 A:miss ──查询数据库──写缓存
线程 B:miss ──查询数据库──写缓存
线程 C:miss ──查询数据库──写缓存

这称为 cache stampede 或缓存击穿的一种表现。sync = true 可以在某些本地缓存场景下协调同一 key 的加载,但其效果依赖具体 Cache 实现,不能自动解决跨节点并发。分布式环境通常还需要分布式锁、预热、随机 TTL、single-flight 或限流。


七、Repository、事务、分页、审计和缓存如何组合

一个合理的查询流程可以是:

请求
 │
 ▼
Controller 校验参数
 │
 ▼
Query Service
 ├─ 生成稳定 Pageable 或游标
 ├─ 进入 readOnly 事务
 ├─ 查询 DTO/投影
 ├─ 组装分页信息
 └─ 返回 API 模型
 │
 ▼
缓存查询结果(可选)

写入流程则不同:

命令
 │
 ▼
Command Service
 ├─ 开启事务
 ├─ 读取 managed 实体
 ├─ 执行业务校验
 ├─ 修改实体
 ├─ 审计字段在持久化生命周期中更新
 ├─ flush / 提交
 └─ 提交后处理缓存失效或发布事件
 │
 ▼
响应

一个更完整的示例:

@Service
public class ProductService {

    private final ProductRepository repository;
    private final ApplicationEventPublisher events;

    public ProductService(
            ProductRepository repository,
            ApplicationEventPublisher events
    ) {
        this.repository = repository;
        this.events = events;
    }

    @Transactional(readOnly = true)
    @Cacheable(
            cacheNames = "productById",
            key = "#id",
            unless = "#result == null"
    )
    public ProductView get(Long id) {
        return repository.findViewById(id)
                .orElseThrow(() -> new NoSuchElementException(
                        "product not found: " + id
                ));
    }

    @Transactional
    public void changePrice(Long id, BigDecimal newPrice) {
        Product product = repository.findById(id)
                .orElseThrow();

        product.changePrice(newPrice);
        events.publishEvent(new ProductChangedEvent(id));
    }
}

查询投影:

public record ProductView(
        Long id,
        String name,
        BigDecimal price
) {
}
@Query("""
       select new com.example.ProductView(
           p.id, p.name, p.price
       )
       from Product p
       where p.id = :id
       """)
Optional<ProductView> findViewById(@Param("id") Long id);

这里的好处是:

  1. 查询只取读模型需要的列;
  2. 不把 Hibernate 实体放入缓存;
  3. 事务负责数据库读取边界;
  4. 写事务负责业务修改;
  5. 缓存失效可以绑定到提交后事件。

但这仍然需要处理缓存事件丢失、读写并发和多实例传播等系统问题。


八、常见误解与诊断方式

1. 误解:调用 save 就立即写库

诊断方法:

  • 打开 Hibernate SQL 日志;
  • 打开绑定参数日志;
  • 观察 SQL 是在 save、查询、flush 还是提交附近出现;
  • 使用数据库端查询确认事务是否已经提交。

如果需要在方法中强制发送 SQL:

repository.saveAndFlush(entity);

saveAndFlush 只保证 flush,不等于事务已经提交。事务随后仍可能回滚。

2. 误解:Page 只执行一次 SQL

诊断 SQL 数量,尤其关注:

内容查询
count 查询
关联懒加载查询

如果列表页返回 20 条,却出现 22 次 SQL,可能是:

1 次内容查询
1 次 count 查询
20 次关联查询

这通常是 N+1 或 DTO 设计问题,而不是分页本身失效。

3. 误解:审计字段能记录所有修改

用批量更新、原生 SQL、数据库脚本修改数据后,实体监听器可能完全没有机会执行。应在数据库层或变更流程中补充审计机制,并验证以下路径:

实体 save
JPQL bulk update
native SQL
数据库管理员脚本
消息消费者
定时任务

每条路径都应明确操作者身份和时间来源。

4. 误解:缓存命中就代表数据可用

缓存可能返回:

  • 已被数据库删除的对象;
  • 用户无权再访问的对象;
  • 旧版本对象;
  • 反序列化失败的对象;
  • 关联信息不完整的对象。

缓存读取后仍应执行必要的权限判断。权限不应只依赖缓存键,除非缓存键明确包含完整安全上下文并且失效策略可证明。

5. 误解:事务越大越安全

事务过大可能导致:

  • 数据库连接长时间占用;
  • 锁持有时间变长;
  • flush 时产生大量 SQL;
  • 并发冲突增多;
  • 外部 HTTP 调用阻塞数据库连接。

例如不要轻易把远程调用放在持有数据库事务的代码中:

@Transactional
public void process() {
    Order order = repository.findById(...).orElseThrow();

    paymentClient.call(); // 远程延迟不可控

    order.markPaid();
}

如果远程调用期间数据库连接和锁一直保持,故障影响会扩大。更复杂的流程应考虑状态机、Outbox、幂等键和补偿,而不是单纯延长事务。


九、Spring Data JPA、Spring Data JDBC 与 MyBatis 的边界

Spring Data JPA 的核心是实体状态、持久化上下文、脏检查和 ORM 关联。适合:

  • 领域对象具有生命周期;
  • 需要实体关系映射;
  • 事务中需要对 managed 实体进行修改;
  • 查询复杂度处于 ORM 可以清晰表达的范围。

Spring Data JDBC 更接近聚合持久化,不维护类似 JPA 的一级缓存和完整实体管理模型。不能把 JPA 的 lazy loading、dirty checking 经验直接迁移过去。

MyBatis 则主要提供 SQL 映射:

应用对象
  │
  ▼
Mapper 方法
  │
  ▼
明确 SQL
  │
  ▼
数据库

它不会因为返回一个对象,就自动拥有 JPA 的 managed 状态和脏检查。缓存、关联加载、批量更新后的对象状态,都需要在 SQL 和应用代码中明确设计。

因此,“Repository”这个名称不能掩盖实现差异:

Spring Data JPA Repository
≈ ORM 实体与查询抽象

MyBatis Mapper
≈ SQL 映射接口

两者都可以放在 Service 事务中,
但对象状态、查询生成、缓存和批量更新语义不同。

同一个系统同时使用 JPA 和 MyBatis 时,还要注意:

  • 是否使用同一个数据源和事务管理器;
  • MyBatis 更新是否能被 JPA 持久化上下文立即看到;
  • JPA 未 flush 的修改是否会影响 MyBatis 查询;
  • 批量 SQL 是否使 JPA 中的实体状态过期。

若在同一事务中混用,通常需要明确 flush、clear 和读取顺序,而不能假设两个框架自动同步内存状态。


十、设计边界的最终判断

可以用下面的因果链检查一个数据访问设计:

数据从哪里来?
  └─ Repository / Mapper / 外部服务

哪些操作必须一起成功?
  └─ Service 的事务边界

结果是否需要总数?
  ├─ 是:Page,接受 count 成本
  └─ 否:Slice 或 keyset

排序是否稳定?
  └─ 所有排序键是否能唯一确定顺序

谁创建、谁修改、何时修改?
  └─ 审计字段、AuditorAware、任务身份

读到的数据允许多旧?
  └─ 缓存 TTL、失效、版本和一致性策略

批量 SQL 是否绕过实体生命周期?
  └─ 手工更新审计、clear/flush、同步缓存

事务提交后缓存失效是否可靠?
  └─ 事务同步、事件、Outbox 或绕过缓存

Repository 解决的是数据访问接口统一问题;事务解决的是数据库事实何时成立;分页解决的是结果集如何分段;审计解决的是元数据追踪;缓存解决的是读路径复用。它们之间最容易出错的地方,正是边界交叉的位置:

  • save 与 flush、commit 不是同一时刻;
  • Page 的窗口与数据库快照不是同一概念;
  • 审计监听器与批量 SQL 不是同一执行路径;
  • 缓存失效与事务提交不是天然原子操作;
  • ORM 实体与缓存对象不是同一种状态对象。

把这些边界显式放进 Service、Repository、数据库和缓存的设计中,才能在正常路径、并发路径和故障路径下得到可解释的数据行为。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。