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();Pageable、Sort支持;- 批量删除等 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
常见关键字包括:
And、OrBetweenLessThan、GreaterThanEqualIsNullLike、ContainingInOrderBy
但是方法名查询存在两个边界。
第一,方法名越长,查询语义越难审查:
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 不等于立即执行 INSERT 或 UPDATE
以 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 的成立条件是:
- 排序字段的组合形成可比较的顺序;
- 游标包含所有排序键;
- 下一页使用与排序方向一致的比较符;
- 过滤条件在前后请求间具有可接受的稳定性。
它仍然不是数据库快照。如果之前的记录被修改,排序位置仍可能变化;但对只追加、按时间顺序读取的场景,通常比大 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);
这里的好处是:
- 查询只取读模型需要的列;
- 不把 Hibernate 实体放入缓存;
- 事务负责数据库读取边界;
- 写事务负责业务修改;
- 缓存失效可以绑定到提交后事件。
但这仍然需要处理缓存事件丢失、读写并发和多实例传播等系统问题。
八、常见误解与诊断方式
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 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Spring Security 完整指南:Filter Chain、认证、授权、JWT 和 CSRF
- 下一篇:Spring Boot 测试:Slice、上下文缓存、MockMvc、容器和契约测试
- 延伸:JPA 与 Hibernate:实体状态、关联、查询、事务和 N+1 诊断
- 延伸:MyBatis 完整基础:Mapper、动态 SQL、缓存、事务和性能边界
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论