前序
第六章 事务与缓存¶
6.0 引言:事务与缓存的平衡艺术¶
在软件架构的世界里,数据持久化层面临着一个永恒的矛盾:我们既渴望铁一般的数据一致性保障,又追求闪电般的响应速度。这就像是要在一场舞会上既要穿着严密的盔甲保护自己,又要轻盈地翩翩起舞——看似不可能的任务。
然而,Jimmer框架通过其独特的设计理念,为我们提供了解决这一矛盾的优雅方案。在前几章中,我们已经探索了Jimmer的核心基石——不可变对象模型、声明式关联和类型安全查询。这些特性为我们构建了坚实的基础。而在本章中,我们将揭开Jimmer最富有魔力的特性之一:事务管理与缓存机制的和谐共舞。
这不仅仅是对两个技术组件的简单介绍,而是对一种思维方式的探索——如何在保障数据完整性的同时,将系统性能推向极致。通过本章的学习,你将掌握在现代高并发环境下平衡一致性与性能的艺术。
6.0.1 数据一致性与性能:看似不可调和的矛盾¶
想象一下这个场景:你正在为一家电商平台开发"双十一"促销系统。随着零点的临近,数以百万计的用户同时涌入平台,疯狂抢购限量商品。在这短短几分钟内,你的系统面临着巨大挑战:
- 每秒数万次的商品库存查询
- 每秒数千次的下单请求
- 绝对不允许出现超卖情况
- 用户期望毫秒级的响应时间
这就是现代企业应用的真实写照——数据一致性和系统性能之间的拉锯战。让我们通过一个简化的测试用例来具体说明这一挑战:
@Test
@DisplayName("测试在事务内的缓存行为")
public void testCacheBehaviorInTransaction() {
// 准备测试数据
Category category = createTestCategory("tc-001", "测试类别");
Product product = createTestProduct("tp-001", "测试商品", new BigDecimal("199.99"), "tc-001");
// 1. 第一次查询 - 从数据库加载并缓存
QueryResult<Product> firstQuery = measureQueryTime(() ->
sqlClient.findById(ProductFetcher.$.allScalarFields().category(
CategoryFetcher.$.name()
), "tp-001")
);
// 2. 更新商品价格
Product updatedProduct = ProductDraft.$.produce(draftObj -> {
draftObj.setId("tp-001");
// 保留其他属性,仅修改价格
draftObj.setPrice(new BigDecimal("299.99"));
});
sqlClient.save(updatedProduct);
// 3. 更新后立即查询 - 验证事务内一致性
Product immediateProduct = sqlClient.findById(
ProductFetcher.$.name().price(),
"tp-001"
);
assertThat(immediateProduct.price()).isEqualTo(new BigDecimal("299.99"));
// 4. 第二次完整查询 - 测试缓存效率
QueryResult<Product> secondQuery = measureQueryTime(() ->
sqlClient.findById(ProductFetcher.$.allScalarFields().category(
CategoryFetcher.$.name()
), "tp-001")
);
// 打印性能对比
System.out.println("First query: " + firstQuery.getExecutionTimeNs() / 1_000_000 + "ms");
System.out.println("Second query: " + secondQuery.getExecutionTimeNs() / 1_000_000 + "ms");
}
这个测试案例揭示了两个核心挑战:
挑战一:数据一致性保障
当我们更新商品价格后,系统必须确保后续所有查询都能立即看到最新价格。想象一下,如果用户下单时看到的是旧价格,而结算时却被收取新价格,这将导致严重的用户投诉和业务混乱。
挑战二:性能优化需求
同时,系统需要高效处理大量查询请求。在没有缓存的情况下,每次查询都会直接访问数据库,导致:
- 数据库负载过重,可能造成系统崩溃
- 查询响应时间延长,用户体验下降
- 系统支持的并发用户数量受限
传统解决方案的困境
面对这一矛盾,传统解决方案通常陷入两难境地:
- 过度强调一致性:完全依赖数据库事务,禁用或最小化缓存使用,导致性能瓶颈
- 过度追求性能:大量使用缓存但缺乏有效的缓存失效机制,导致数据不一致
- 手动平衡:开发者手动编写复杂的缓存管理代码,容易出错且维护成本高
这种情况就像一个人同时要在两艘渐行渐远的船上保持平衡——几乎不可能完美实现。
实际生产环境中这种矛盾更加明显。以电商为例,一个典型的商品详情页可能需要:
- 商品基本信息(商品表)
- 商品库存状态(库存表)
- 商品评价统计(评价表)
- 商品促销信息(促销表)
- 相关商品推荐(商品关联表)
如果每次访问都从数据库实时查询这些数据,系统将难以支撑大流量;但如果简单地缓存所有数据,又会面临数据更新后缓存未及时失效的问题。
6.0.2 事务与缓存:相互影响的共生关系¶
事务管理与缓存机制不是相互隔离的技术领域,而是紧密交织的共生关系。这种关系的复杂性远超大多数开发者的认知,导致许多系统设计中存在潜在的数据一致性隐患。
让我们通过一个多线程事务测试深入理解这种关系:
@Test
@DisplayName("测试事务隔离级别对缓存效果的影响")
public void testTransactionIsolationWithCache() throws Exception {
// 准备测试数据
Category category = createTestCategory("tc-002", "电子设备");
Product product = createTestProduct("tp-002", "智能手表", new BigDecimal("1299.99"), "tc-002");
// 模拟两个并发事务
CountDownLatch latch1 = new CountDownLatch(1); // 控制事务顺序
CountDownLatch latch2 = new CountDownLatch(1);
// 事务1: 更新商品价格
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> {
executeTransaction1("tp-002", latch1, latch2); // 更新价格为1499.99
});
// 事务2: 读取商品价格
List<BigDecimal> priceInTransaction2 = new ArrayList<>();
executor.submit(() -> {
BigDecimal price = executeTransaction2("tp-002", latch1, latch2);
priceInTransaction2.add(price);
});
// 等待两个事务完成
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);
// 验证最终价格
Product finalProduct = sqlClient.findById(ProductFetcher.$.price(), "tp-002");
BigDecimal transaction2Price = priceInTransaction2.isEmpty() ? null : priceInTransaction2.get(0);
System.out.println("Final price: " + finalProduct.price());
System.out.println("Price seen in transaction 2: " + transaction2Price);
}
这个测试并不只是在验证事务的隔离级别,它实际上揭示了事务与缓存之间的四种关键互动模式:
1. 写事务对缓存一致性的影响
当事务1更新商品价格时,系统必须确保: - 更新在事务提交前对其他事务不可见(隔离性) - 更新在事务提交后对所有后续查询立即可见(一致性) - 相关缓存项目必须在合适的时机失效(缓存同步)
2. 事务隔离级别对缓存策略的约束
不同的事务隔离级别需要不同的缓存处理策略:
- READ_UNCOMMITTED:理论上可以缓存未提交的数据(极少使用)
- READ_COMMITTED:只能缓存已提交的数据,需要在每个语句执行时检查缓存有效性
- REPEATABLE_READ:事务开始后可以使用缓存,但缓存项必须在事务范围内一致
- SERIALIZABLE:最严格的隔离级别,对缓存使用有极大限制
3. 缓存策略对事务性能的影响
合理的缓存策略可以显著提升事务性能:
- 减少事务内的数据库访问次数
- 降低事务执行时间,减少锁持有时间
- 提高系统整体并发能力
4. 分布式环境中的缓存一致性挑战
在分布式系统中,这种关系更加复杂:
- 多节点间的缓存同步问题
- 分布式事务与分布式缓存的协调
- 网络延迟导致的缓存不一致窗口期
真实世界的复杂场景
考虑一个真实的业务场景:用户下单流程。这个过程涉及多个微服务和多层缓存:
- 用户服务:验证用户状态和余额
- 商品服务:检查商品信息和库存
- 促销服务:计算适用折扣
- 订单服务:创建订单记录
- 库存服务:扣减库存数量
在这个流程中,缓存与事务的交互错综复杂:
- 如果用户服务缓存了用户余额,但支付服务更新了余额,会导致下单时使用过期数据
- 如果商品促销信息被缓存但未及时更新,用户可能看到错误的价格
- 如果库存缓存未能及时反映变化,可能导致超卖
这正是许多系统在高并发场景下容易出现一致性问题的根源。
6.0.3 Jimmer的一体化解决方案¶
面对上述挑战,Jimmer提供了一种突破性的解决方案,将事务管理与缓存机制视为一个有机整体。我们先通过一个性能测试用例,直观感受Jimmer缓存的效能:
@Test
@DisplayName("测试批量操作中的缓存效率")
public void testCacheEfficiencyInBulkOperations() {
// 创建测试类别和商品
Category category = createTestCategory("tc-003", "家用电器");
for (int i = 0; i < 10; i++) {
createTestProduct(
"tp-bulk-" + i,
"家电产品" + i,
new BigDecimal("1000").add(new BigDecimal(i * 100)),
"tc-003"
);
}
// 首次批量查询 - 无缓存
long start1 = System.nanoTime();
for (int i = 0; i < 10; i++) {
Product product = sqlClient.findById(
ProductFetcher.$.name().price().category(CategoryFetcher.$.name()),
"tp-bulk-" + i
);
}
long firstBatchTime = System.nanoTime() - start1;
// 再次批量查询 - 有缓存
long start2 = System.nanoTime();
for (int i = 0; i < 10; i++) {
Product product = sqlClient.findById(
ProductFetcher.$.name().price().category(CategoryFetcher.$.name()),
"tp-bulk-" + i
);
}
long secondBatchTime = System.nanoTime() - start2;
// 输出性能对比
System.out.println("首次查询: " + firstBatchTime / 1_000_000 + "ms");
System.out.println("二次查询: " + secondBatchTime / 1_000_000 + "ms");
System.out.println("性能提升: " + (double)firstBatchTime / secondBatchTime + "倍");
}
这个测试揭示了缓存对性能的巨大影响。在实际测试中,我们通常能观察到5-10倍甚至更高的性能提升。但Jimmer的价值不仅仅在于速度,更在于其解决一致性挑战的创新方案。
Jimmer的核心理念:以不变应万变
Jimmer建立在不可变对象模型的基础上。这种设计选择看似简单,但对缓存与事务协同具有革命性影响:
- 不变性消除了缓存一致性的根本难题
传统ORM中,缓存对象可能被修改,导致同一对象ID对应的多个缓存副本相互矛盾。而在Jimmer中,一个对象一旦创建就永远不会变化—就像数据库中的快照一样。所有的"修改"操作实际上是创建新对象,新对象有自己的缓存条目,从根本上避免了缓存不一致问题。
- 对象粒度的精确缓存控制
Jimmer不是简单地以表或查询为单位管理缓存,而是以对象及其关联为单位。当某个属性发生变化时,系统能精确识别受影响的缓存条目,避免了过度失效带来的性能损失。
Jimmer的四大技术优势
- 事务感知的缓存操作
Jimmer缓存层能够感知事务上下文,确保缓存操作与事务状态保持同步: - 事务提交时自动处理缓存一致性 - 事务回滚时撤销缓存操作 - 根据隔离级别调整缓存行为
- 多级缓存架构
Jimmer实现了精心设计的多级缓存体系: - L1缓存:事务级缓存,确保事务内一致性 - L2缓存:应用级缓存,提升单实例性能 - L3缓存:分布式缓存,支持集群环境
- 声明式缓存管理
开发者无需编写繁琐的缓存操作代码,而是通过声明式配置管理缓存行为: - 实体级缓存控制 - 查询级缓存策略 - 自定义缓存键生成
- 智能缓存预热与异步化
为进一步提升性能,Jimmer提供了先进的缓存优化机制: - 热点数据预加载 - 非阻塞式缓存重建 - 关联数据智能预取
核心场景的质的飞跃
这些技术创新在实际应用中带来了质的飞跃:
- 高并发商品详情页
传统实现中,开发者需要手动管理多个缓存层并处理一致性问题;而使用Jimmer,只需定义实体关系和缓存策略,框架自动处理一切。系统能够同时支持高频读取和实时库存更新,同时保证数据一致性。
- 订单处理流程
在传统实现中,订单创建往往需要多个步骤的手动事务与缓存协调;而使用Jimmer,整个流程可以在单一事务中完成,框架自动处理相关缓存的更新和失效,大幅简化代码并提高可靠性。
- 实时数据分析
对于需要同时支持OLTP和OLAP的系统,Jimmer的缓存层可以智能区分不同类型的查询,为分析型查询提供专门的缓存策略,在不影响事务处理的同时支持高效的数据分析。
与传统解决方案相比,Jimmer的方案优势显著:
| 特性 | Jimmer | Hibernate + Spring Cache | MyBatis + Redis |
|---|---|---|---|
| 开发复杂度 | 低 | 中 | 高 |
| 一致性保障 | 自动 | 部分自动 | 手动 |
| 性能优化空间 | 大 | 中 | 大 |
| 维护成本 | 低 | 中 | 高 |
| 类型安全 | 完全 | 部分 | 有限 |
| 分布式支持 | 内置 | 需配置 | 需手动实现 |
6.0.4 学习路线图:从理论到实践的全方位探索¶
本章将带领读者全面深入地探索Jimmer的事务与缓存机制,从理论基础到高级应用,从核心原理到性能优化。这不仅是对技术的学习,更是对软件设计思想的升华。
学习路径设计
我们的学习路径遵循"先基础后高级、先原理后应用"的渐进式设计:
内容地图
- 事务管理基础
- Spring事务集成机制
- 声明式与编程式事务
- 事务传播与隔离级别
-
Jimmer特有的事务增强
-
缓存架构设计
- 多级缓存架构
- 缓存配置与自定义
- 对象缓存与查询缓存
-
缓存生命周期管理
-
事务与缓存协同
- 事务感知的缓存操作
- 缓存一致性保障机制
- 不可变对象模型的优势
-
分布式环境中的挑战
-
高级应用场景
- 嵌套事务处理
- 跨实体事务
- 条件化缓存策略
-
大数据量场景优化
-
性能监控与调优
- 缓存命中率分析
- 事务性能瓶颈识别
- 参数优化指南
-
压力测试方法
-
实战案例分析
- 电商平台商品系统
- 社交媒体信息流
- 金融系统账户处理
- 内容平台全文搜索
让我们怀着求知的热情,踏上探索Jimmer事务与缓存协同机制的旅程,从理论原理到实战案例,全方位理解如何在现代企业应用中平衡数据一致性与系统性能这一永恒的挑战。
6.1 事务管理基础:确保数据一致性的基石¶
在上一节中,我们探讨了事务管理与缓存机制作为现代企业级应用的两大支柱,如何共同保障数据一致性并优化系统性能。本节,我们将深入探究事务管理的基础知识,这是构建可靠数据操作的核心基石。
6.1.1 什么是事务?¶
事务是数据库管理系统(DBMS)中的一个核心概念,它代表一组操作,这些操作要么全部成功执行并持久化到数据库中,要么全部失败并回滚到初始状态。事务的本质是将多个数据修改操作打包成一个不可分割的工作单元,确保数据始终处于一致状态。
想象一个简单的场景:在电商系统中,当用户下单时,至少涉及两个关键操作:
- 减少商品库存
- 创建订单记录
如果没有事务保障,可能会出现库存已减少但订单未创建,或者订单已创建但库存未减少的不一致状态。无论哪种情况,都会导致业务异常和数据混乱。事务机制确保这两个操作要么都成功,要么都不执行,从而保持系统的数据一致性。
以下是一个基本的事务示例:
@Transactional
public Order createOrder(String productId, int quantity, String userId) {
// 查询商品
Product product = sqlClient.findById(Product.class, productId);
if (product == null || product.stock() < quantity) {
throw new InsufficientStockException("库存不足");
}
// 减少库存
Product updatedProduct = ProductDraft.$.produce(draft -> {
draft.setId(productId);
draft.setStock(product.stock() - quantity);
});
sqlClient.save(updatedProduct);
// 创建订单
Order draft = OrderDraft.$.produce(draft -> {
draft.setId(UUID.randomUUID().toString());
draft.setProductId(productId);
draft.setQuantity(quantity);
draft.setUserId(userId);
draft.setTotalPrice(product.price().multiply(new BigDecimal(quantity)));
draft.setStatus("CREATED");
draft.setCreatedTime(LocalDateTime.now());
});
return sqlClient.save(draft).getModifiedEntity();
}
在这个示例中,@Transactional注解确保所有数据库操作作为一个单一事务执行。如果在任何步骤抛出异常(例如库存不足),所有已执行的数据库更改都会被回滚,保持数据一致性。
6.1.2 事务的ACID特性¶
事务的可靠性基于其四个关键特性,通常简称为ACID:
- 原子性(Atomicity)
事务是不可分割的工作单元,其中的操作要么全部完成,要么全部不完成。原子性确保数据修改不会被部分应用,这对于维护数据完整性至关重要。
以下测试用例演示了原子性的概念:
@Test
@DisplayName("测试事务的原子性")
@Transactional
public void testTransactionAtomicity() {
// 准备测试数据
final String CATEGORY_ID = "tx-basic-01";
final String PRODUCT_ID = "tx-prod-01";
try {
// 清理可能的测试残留数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
// 验证清理成功,初始状态没有数据
assertThat(sqlClient.findById(Product.class, PRODUCT_ID)).isNull();
assertThat(sqlClient.findById(Category.class, CATEGORY_ID)).isNull();
// 使用assertThrows捕获预期的异常
assertThrows(RuntimeException.class, () -> {
// 在当前事务中创建类别和商品
System.out.println("创建类别: " + CATEGORY_ID);
Category category = createTestCategory(CATEGORY_ID, "原子性测试类别");
System.out.println("创建商品: " + PRODUCT_ID);
Product product = createTestProduct(PRODUCT_ID, "原子性测试商品",
new BigDecimal("199.99"), CATEGORY_ID);
// 验证数据在事务中已创建
Category checkCategory = sqlClient.findById(Category.class, CATEGORY_ID);
Product checkProduct = sqlClient.findById(Product.class, PRODUCT_ID);
assertThat(checkCategory).isNotNull();
assertThat(checkProduct).isNotNull();
// 故意抛出异常,应该触发整个事务回滚
throw new RuntimeException("故意抛出异常以测试事务原子性");
});
} finally {
// 确保测试后清理数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
@Test
@DisplayName("验证事务的原子性回滚")
public void testTransactionAtomicityVerification() {
// 准备测试数据
final String CATEGORY_ID = "tx-basic-01";
final String PRODUCT_ID = "tx-prod-01";
try {
// 预期情况:上一个测试抛出异常后应该回滚事务
// 所以此处的数据应该不存在
Product product = sqlClient.findById(Product.class, PRODUCT_ID);
Category category = sqlClient.findById(Category.class, CATEGORY_ID);
// 验证数据不存在,说明事务回滚成功,符合原子性
assertThat(product).isNull();
assertThat(category).isNull();
} finally {
// 确保测试后清理数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
这个测试用例创建了一个商品类别和一个商品,然后故意抛出异常。由于事务的原子性,尽管数据在事务内被成功创建并可见,一旦事务回滚,所有更改都会被撤销,就像从未发生过一样。第二个测试方法验证了数据确实没有被持久化到数据库,证明了事务回滚的有效性。
- 一致性(Consistency)
事务应当使数据库从一个一致状态转变为另一个一致状态。一致性确保无论事务是成功完成还是失败回滚,数据库都维持预定义的完整性规则。
@Test
@DisplayName("测试事务的一致性")
public void testTransactionConsistency() {
// 准备测试数据
final String CATEGORY_ID = "tx-basic-02";
final String PRODUCT_ID = "tx-prod-02";
try {
// 创建类别
Category category = createTestCategory(CATEGORY_ID, "一致性测试类别");
// 使用编程式事务更新商品
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
// 1. 创建商品
transactionTemplate.execute(status -> {
createTestProduct(PRODUCT_ID, "一致性测试商品", new BigDecimal("299.99"), CATEGORY_ID);
return null;
});
// 2. 验证商品存在且价格正确
Product product = sqlClient.findById(ProductFetcher.$.price().stock(), PRODUCT_ID);
assertThat(product).isNotNull();
assertThat(product.price()).isEqualTo(new BigDecimal("299.99"));
assertThat(product.stock()).isEqualTo(100);
// 3. 在事务中更新商品价格和库存
transactionTemplate.execute(status -> {
// 读取当前商品
Product currentProduct = sqlClient.findById(Product.class, PRODUCT_ID);
// 创建更新对象 - 减少库存并更新价格
Product updatedProduct = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID);
draft.setPrice(new BigDecimal("249.99"));
draft.setStock(80);
});
// 执行更新
sqlClient.save(updatedProduct);
return null;
});
// 4. 验证更新后数据的一致性
Product updatedProduct = sqlClient.findById(ProductFetcher.$.price().stock(), PRODUCT_ID);
assertThat(updatedProduct.price()).isEqualTo(new BigDecimal("249.99"));
assertThat(updatedProduct.stock()).isEqualTo(80);
} finally {
// 清理测试数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
在这个测试中,我们验证了商品价格和库存的更新在事务提交后能够正确反映到数据库中,保持数据的一致性。事务确保价格和库存的修改要么都成功,要么都不发生,避免了部分更新导致的数据不一致。
- 隔离性(Isolation)
隔离性确保并发执行的事务相互隔离,一个事务的操作在完成之前对其他事务不可见。隔离性防止了脏读、不可重复读和幻读等并发问题。
@Test
@DisplayName("测试事务的隔离性 - READ_COMMITTED")
public void testTransactionIsolation_ReadCommitted() throws Exception {
// 准备测试数据
final String CATEGORY_ID = "tx-basic-03";
final String PRODUCT_ID = "tx-prod-03";
final BigDecimal INITIAL_PRICE = new BigDecimal("399.99");
try {
// 创建类别和商品
Category category = createTestCategory(CATEGORY_ID, "隔离性测试类别");
Product product = createTestProduct(PRODUCT_ID, "隔离性测试商品", INITIAL_PRICE, CATEGORY_ID);
// 使用CountDownLatch确保两个事务按正确顺序执行
CountDownLatch latch1 = new CountDownLatch(1);
CountDownLatch latch2 = new CountDownLatch(1);
// 存储事务中观察到的价格
List<BigDecimal> pricesInTransaction1 = new ArrayList<>();
List<BigDecimal> pricesInTransaction2 = new ArrayList<>();
// 创建两个执行线程
ExecutorService executor = Executors.newFixedThreadPool(2);
// 第一个事务:长事务,在提交前读取两次
executor.submit(() -> {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
def.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 第一次读取
Product p1 = sqlClient.findById(ProductFetcher.$.price(), PRODUCT_ID);
pricesInTransaction1.add(p1.price());
// 通知事务2可以开始
latch1.countDown();
// 等待事务2执行完毕
latch2.await(5, TimeUnit.SECONDS);
// 第二次读取 - 在READ_COMMITTED下,应该能看到事务2已提交的更改
Product p2 = sqlClient.findById(ProductFetcher.$.price(), PRODUCT_ID);
pricesInTransaction1.add(p2.price());
// 提交事务
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
e.printStackTrace();
}
});
// 第二个事务:短事务,更新价格并提交
executor.submit(() -> {
try {
// 等待事务1执行第一次读取
latch1.await(5, TimeUnit.SECONDS);
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 读取当前价格
Product p = sqlClient.findById(ProductFetcher.$.price(), PRODUCT_ID);
pricesInTransaction2.add(p.price());
// 更新价格
Product updatedProduct = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID);
draft.setPrice(new BigDecimal("499.99"));
});
sqlClient.save(updatedProduct);
// 提交事务
transactionManager.commit(status);
// 通知事务1继续执行
latch2.countDown();
} catch (Exception e) {
transactionManager.rollback(status);
e.printStackTrace();
}
} catch (Exception e) {
e.printStackTrace();
}
});
// 等待所有任务完成
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);
// 验证结果
assertThat(pricesInTransaction1).hasSize(2);
assertThat(pricesInTransaction2).hasSize(1);
// 在READ_COMMITTED隔离级别下:
// 1. 事务1的第一次读取应该看到初始价格
assertThat(pricesInTransaction1.get(0)).isEqualTo(INITIAL_PRICE);
// 2. 事务2应该看到初始价格
assertThat(pricesInTransaction2.get(0)).isEqualTo(INITIAL_PRICE);
// 3. 事务1的第二次读取应该看到事务2提交的新价格
assertThat(pricesInTransaction1.get(1)).isEqualTo(new BigDecimal("499.99"));
} finally {
// 清理测试数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
这个测试演示了READ_COMMITTED隔离级别的行为。在此隔离级别下,一个事务只能看到其他事务已提交的更改,但不能看到未提交的更改。我们使用两个并发事务和CountDownLatch来控制执行顺序,验证事务1在第二次读取时能够看到事务2已提交的价格更新。
- 持久性(Durability)
持久性确保一旦事务提交,其结果将永久保存在数据库中,即使在系统崩溃后也是如此。持久性通常通过数据库的日志机制实现,确保已提交的事务不会丢失。
@Test
@DisplayName("测试事务的持久性")
public void testTransactionDurability() {
// 准备测试数据
final String CATEGORY_ID = "tx-basic-05";
final String PRODUCT_ID = "tx-prod-05";
try {
// 清理可能的测试残留数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
// 第一阶段:在事务中创建数据
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.execute(status -> {
// 创建类别
System.out.println("创建类别: " + CATEGORY_ID);
Category category = createTestCategory(CATEGORY_ID, "持久性测试类别");
// 创建商品
System.out.println("创建商品: " + PRODUCT_ID);
Product product = createTestProduct(PRODUCT_ID, "持久性测试商品", new BigDecimal("399.99"), CATEGORY_ID);
System.out.println("提交事务");
return null; // 此处返回null表示事务正常提交
});
// 第二阶段:验证数据持久化到数据库
System.out.println("验证数据持久性");
// 模拟应用重启后数据持久存在
System.out.println("模拟应用重启后数据持久存在");
// 以新事务查询,验证数据持久存在
Product product = transactionTemplate.execute(status -> {
return sqlClient.findById(Product.class, PRODUCT_ID);
});
Category category = transactionTemplate.execute(status -> {
return sqlClient.findById(Category.class, CATEGORY_ID);
});
// 验证数据持久化正确
assertThat(product).isNotNull();
assertThat(product.name()).isEqualTo("持久性测试商品");
assertThat(product.price()).isEqualTo(new BigDecimal("399.99"));
assertThat(category).isNotNull();
assertThat(category.name()).isEqualTo("持久性测试类别");
} finally {
// 清理测试数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
这个测试模拟了事务提交后数据的持久性。我们在第一个事务中创建商品和类别并提交,然后在新的事务中验证数据仍然存在。在实际系统中,持久性的真正测试需要在数据库或应用重启后进行验证,但这个测试展示了基本概念。
6.1.3 事务传播行为¶
在现代企业应用中,业务逻辑通常涉及多个服务方法的调用。当这些方法都有事务需求时,就需要明确定义它们之间的事务边界。事务传播行为(Transaction Propagation)定义了当一个事务性方法被另一个事务性方法调用时应该如何表现。
在Spring框架中,定义了以下七种传播行为:
- REQUIRED(默认):如果当前存在事务,则加入该事务;如果不存在事务,则创建新事务。
- SUPPORTS:如果当前存在事务,则加入该事务;如果不存在事务,则以非事务方式执行。
- MANDATORY:如果当前存在事务,则加入该事务;如果不存在事务,则抛出异常。
- REQUIRES_NEW:创建一个新事务,如果当前存在事务,则挂起当前事务。
- NOT_SUPPORTED:以非事务方式执行,如果当前存在事务,则挂起当前事务。
- NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
- NESTED:如果当前存在事务,则创建一个嵌套事务;如果不存在事务,则创建新事务。
以下测试用例展示了REQUIRED和REQUIRES_NEW的区别:
@Test
@DisplayName("测试不同的事务传播行为")
public void testTransactionPropagationBehaviors() {
// 准备测试数据
final String CATEGORY_ID = "tx-basic-04";
final String PRODUCT_ID = "tx-prod-04";
try {
// 创建类别
createTestCategory(CATEGORY_ID, "传播行为测试类别");
// 1. 测试REQUIRED传播行为(默认行为)
executeRequiredTransaction(PRODUCT_ID, CATEGORY_ID);
// 验证商品已创建
Product product = sqlClient.findById(ProductFetcher.$.name().price(), PRODUCT_ID);
assertThat(product).isNotNull();
assertThat(product.name()).isEqualTo("传播行为测试商品");
// 2. 测试REQUIRES_NEW传播行为
final BigDecimal NEW_PRICE = new BigDecimal("399.99");
updateWithRequiresNewTransaction(PRODUCT_ID, NEW_PRICE);
// 验证价格已更新
product = sqlClient.findById(ProductFetcher.$.price(), PRODUCT_ID);
assertThat(product.price()).isEqualTo(NEW_PRICE);
} finally {
// 清理测试数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
/**
* 使用REQUIRED传播行为的事务方法
*/
@Transactional(propagation = Propagation.REQUIRED)
public void executeRequiredTransaction(String productId, String categoryId) {
// 创建商品
createTestProduct(productId, "传播行为测试商品", new BigDecimal("299.99"), categoryId);
// 内部调用其他方法 - 应该使用当前事务
validateProductCreation(productId, "传播行为测试商品");
}
/**
* 内部验证方法 - 默认使用当前事务
*/
@Transactional(propagation = Propagation.REQUIRED)
public void validateProductCreation(String productId, String expectedName) {
// 在当前事务中验证产品创建
Product product = sqlClient.findById(ProductFetcher.$.name(), productId);
assertThat(product).isNotNull();
assertThat(product.name()).isEqualTo(expectedName);
}
/**
* 使用REQUIRES_NEW传播行为的事务方法
*/
@Transactional(propagation = Propagation.REQUIRED)
public void updateWithRequiresNewTransaction(String productId, BigDecimal newPrice) {
// 在当前事务中读取价格
Product product = sqlClient.findById(ProductFetcher.$.price(), productId);
System.out.println("当前事务中的价格: " + product.price());
// 在新事务中更新价格 - 使用REQUIRES_NEW创建独立事务
updatePriceInNewTransaction(productId, newPrice);
// 此处在当前事务中可能看不到新事务中的更新,取决于隔离级别
}
/**
* 使用REQUIRES_NEW创建独立事务
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updatePriceInNewTransaction(String productId, BigDecimal newPrice) {
// 更新商品价格
Product updatedProduct = ProductDraft.$.produce(draft -> {
draft.setId(productId);
draft.setPrice(newPrice);
});
sqlClient.save(updatedProduct);
}
这个测试用例演示了两种主要的传播行为:
-
REQUIRED:
executeRequiredTransaction和validateProductCreation方法使用同一个事务。这确保它们要么都成功,要么都失败,保持数据一致性。 -
REQUIRES_NEW:
updatePriceInNewTransaction方法创建一个新的、独立的事务,与调用它的updateWithRequiresNewTransaction方法的事务无关。这意味着即使外部事务失败并回滚,内部事务仍然可以提交,反之亦然。
选择合适的传播行为对于设计复杂业务逻辑至关重要,它可以确保数据一致性,同时避免事务过大导致的性能问题。
6.1.4 事务超时与只读事务¶
除了基本的ACID特性和传播行为外,事务管理还提供了超时和只读等高级特性,以进一步优化性能和资源使用。
- 事务超时
事务超时限制了事务执行的最长时间,如果事务在指定时间内未完成,将被强制回滚。这对于防止长时间运行的事务锁定资源和影响系统性能非常重要。
@Test
@DisplayName("测试事务超时设置")
public void testTransactionTimeout() {
// 准备测试数据
final String CATEGORY_ID = "tx-basic-06";
final String PRODUCT_ID = "tx-prod-06";
try {
// 清理可能的测试残留数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
// 创建类别(事务外)
System.out.println("创建类别: " + CATEGORY_ID);
Category category = createTestCategory(CATEGORY_ID, "超时测试类别");
// 演示如何配置事务超时
System.out.println("演示事务超时配置");
// 1. 使用TransactionTemplate
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.setTimeout(30); // 设置30秒超时
System.out.println("TransactionTemplate已配置30秒超时");
// 2. 使用TransactionDefinition
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setTimeout(10); // 设置10秒超时
System.out.println("TransactionDefinition已配置10秒超时");
// 3. 使用@Transactional注解(这里只是说明,不实际执行)
System.out.println("@Transactional也可以设置超时: @Transactional(timeout = 30)");
// 执行一个简单的事务,不实际测试超时
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 创建商品
System.out.println("在配置了超时的事务中创建商品: " + PRODUCT_ID);
Product product = createTestProduct(PRODUCT_ID, "超时测试商品", new BigDecimal("599.99"), CATEGORY_ID);
// 正常提交事务
System.out.println("提交事务");
transactionManager.commit(status);
// 验证商品已创建
Product createdProduct = sqlClient.findById(Product.class, PRODUCT_ID);
assertThat(createdProduct).isNotNull();
assertThat(createdProduct.name()).isEqualTo("超时测试商品");
System.out.println("商品创建成功,超时设置演示完毕");
} catch (Exception e) {
// 如果发生异常,回滚事务
System.out.println("发生异常,回滚事务: " + e.getMessage());
transactionManager.rollback(status);
throw e;
}
} finally {
// 清理测试数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
这个测试用例展示了如何在不同情境下设置事务超时:使用TransactionTemplate、使用TransactionDefinition或使用@Transactional注解。在实际应用中,超时设置应根据业务需求和系统性能特性进行调整。
- 只读事务
对于纯粹的读取操作,可以将事务标记为只读,这样数据库可以进行相应的优化,例如避免获取不必要的锁,提高并发性能。
@Transactional(readOnly = true)
public List<Product> findProductsByCategory(String categoryId) {
return sqlClient.createQuery(ProductTable.$)
.where(ProductTable.$.categoryId().eq(categoryId))
.select(ProductTable.$)
.execute();
}
在Spring中,可以通过@Transactional(readOnly = true)注解将事务标记为只读。这告诉数据库和ORM框架该事务不会修改数据,从而可以应用相应的优化。
6.1.5 Spring事务管理与Jimmer集成¶
Spring框架提供了全面的事务管理支持,而Jimmer与Spring无缝集成,使开发者可以轻松利用Spring的事务能力。Spring事务管理主要通过两种方式实现:
- 声明式事务管理
声明式事务是最常用的方式,通过@Transactional注解轻松定义事务边界和特性。
@Service
public class ProductService {
@Autowired
private JSqlClient sqlClient;
@Transactional
public Product createProduct(ProductInput input, String categoryId) {
// 验证类别存在
Category category = sqlClient.findById(Category.class, categoryId);
if (category == null) {
throw new EntityNotFoundException("类别不存在: " + categoryId);
}
// 创建商品
Product draft = ProductDraft.$.produce(p -> {
p.setId(UUID.randomUUID().toString());
p.setName(input.getName());
p.setPrice(input.getPrice());
p.setStock(input.getStock());
p.setActive(true);
p.setDescription(input.getDescription());
p.setCategoryId(categoryId);
p.setCreatedTime(LocalDateTime.now());
});
return sqlClient.save(draft).getModifiedEntity();
}
@Transactional(readOnly = true)
public List<Product> findActiveProducts() {
return sqlClient.createQuery(ProductTable.$)
.where(ProductTable.$.active().eq(true))
.select(ProductTable.$)
.execute();
}
}
在上面的例子中,我们为商品创建方法应用了标准事务,为商品查询方法应用了只读事务。Spring会自动处理事务的开启、提交或回滚,大大简化了开发工作。
- 编程式事务管理
有时,我们需要更精细地控制事务边界,例如在一个方法中执行多个独立事务,或者动态决定是否需要事务。这时可以使用编程式事务管理:
@Service
public class InventoryService {
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private JSqlClient sqlClient;
public void processInventoryAdjustments(List<InventoryAdjustment> adjustments) {
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
for (InventoryAdjustment adjustment : adjustments) {
// 为每个库存调整创建单独的事务
transactionTemplate.execute(status -> {
try {
Product product = sqlClient.findById(Product.class, adjustment.getProductId());
if (product == null) {
// 忽略不存在的商品
return null;
}
// 更新库存
Product updatedProduct = ProductDraft.$.produce(draft -> {
draft.setId(product.id());
draft.setStock(product.stock() + adjustment.getQuantityChange());
draft.setModifiedTime(LocalDateTime.now());
});
sqlClient.save(updatedProduct);
return null;
} catch (Exception e) {
// 记录错误并标记当前事务回滚
status.setRollbackOnly();
logInventoryError(adjustment, e);
return null;
}
});
}
}
private void logInventoryError(InventoryAdjustment adjustment, Exception e) {
// 记录错误日志
System.err.println("调整库存时发生错误: " + adjustment.getProductId() + ", " + e.getMessage());
}
}
在这个例子中,我们为每个库存调整创建单独的事务,这样一个调整的失败不会影响其他调整。同时,我们还利用了事务状态对象(TransactionStatus)的setRollbackOnly()方法来明确标记事务应该回滚。
- Jimmer与Spring事务的集成特性
Jimmer与Spring事务管理的集成不仅限于基本用法,还提供了一些高级特性:
-
实体缓存与事务一致性:Jimmer的实体缓存能够感知事务边界,确保在同一事务中读取到一致的数据。
-
乐观锁与版本控制:Jimmer支持通过
@Version注解实现乐观锁,自动检测并处理并发更新冲突。 -
动态SQL与事务:Jimmer的动态SQL功能能够在事务上下文中安全工作,保持一致性。
-
批量操作优化:在事务中执行批量操作时,Jimmer能够智能优化SQL执行,提高性能同时保持事务安全。
6.1.6 小结¶
在本节中,我们深入探讨了事务管理的基础知识,包括ACID特性、传播行为、隔离级别、事务超时等核心概念。通过实际示例,我们展示了如何在Jimmer和Spring框架中使用声明式和编程式事务管理。良好的事务管理是构建可靠、高性能数据应用的基石,它确保了数据操作的安全性和一致性。
在下一节中,我们将探索Jimmer的缓存机制,了解如何在保持数据一致性的同时提升系统性能。事务管理与缓存是相辅相成的:事务确保数据操作的正确性,而缓存则提高数据访问的效率。两者结合,构成了现代企业级应用数据层的完整解决方案。
6.2 缓存架构设计:性能与一致性的平衡艺术¶
在高并发系统设计中,有一个永恒的挑战:如何在保证数据一致性的同时,提供闪电般的响应速度?数据库通常成为系统瓶颈,而缓存作为解决方案却引入了全新的复杂性——缓存一致性。想象一下一家电商平台在"双十一"期间,数百万用户同时抢购限量商品,系统需要同时应对:
- 高频次的商品信息查询(每秒数十万次)
- 频繁的库存更新(每秒数千次)
- 实时的价格变化和促销计算
如果没有缓存,系统将崩溃;但如果缓存管理不当,用户可能看到错误的库存或过期的价格,导致糟糕的用户体验甚至业务损失。Jimmer的缓存架构正是为解决这一矛盾而精心设计的。
6.2.1 缓存的本质与认知重构¶
传统缓存思维的局限
传统ORM框架中,缓存通常被视为一个独立的"附加功能",与核心持久化逻辑分离,这导致一系列问题:
// 伪代码:传统ORM框架中的缓存管理
@Service
public class ProductService {
@Autowired
private ProductRepository repository;
@Autowired
private CacheManager cacheManager;
@Cacheable(key = "product:{#id}")
public Product getProduct(String id) {
return repository.findById(id).orElse(null);
}
@Transactional
@CacheEvict(key = "product:{#product.id}")
public void updateProduct(Product product) {
// 尝试更新缓存
try {
repository.save(product);
// 此时若事务回滚,缓存已被清除,造成不一致
// 如果产品有关联对象,相关缓存可能未被清除
} catch (Exception e) {
// 处理异常,但缓存可能已被清除
throw e;
}
}
// 更复杂的场景:关联缓存
@Cacheable(key = "product-with-details:{#id}")
public ProductDetails getProductWithDetails(String id) {
// 包含多层关联的查询
// 如果任何关联对象更新,此缓存需要手动清除
}
}
这种方式存在的核心问题:
- 事务边界外的缓存操作:缓存操作可能在事务完成前执行,导致事务回滚后缓存状态不一致
- 关联对象的缓存同步:当更新一个对象时,所有包含该对象的复合缓存需要手动识别并清除
- 缓存粒度管理困难:难以实现字段级的缓存策略定制
- 缓存操作分散:缓存逻辑散布在业务代码中,维护困难
在高并发场景下,这些问题可能导致严重的数据不一致,比如: - 用户A更新商品价格,事务失败并回滚,但缓存已被清除,导致缓存未命中 - 用户B更新了商品,但包含该商品的分类页面缓存未失效,用户看到旧数据 - 多个微服务实例各自维护缓存,导致不同用户看到不同版本的数据
Jimmer的缓存认知重构
Jimmer对缓存的理解完全不同——缓存不是一个附加功能,而是持久化系统的内在组成部分。这一理念转变带来了架构上的根本性创新:
- 缓存与实体模型一体化:缓存不是对SQL结果的简单存储,而是对实体图的精确镜像
- 缓存操作内嵌于事务:缓存更新成为事务的一部分,共享相同的原子性保证
- 关联感知的缓存结构:缓存系统理解实体间的关联关系,能够自动处理级联更新
- 声明式而非命令式:开发者声明缓存策略,而不是编写缓存操作代码
实体缓存的分类与层次
在深入Jimmer的缓存架构之前,我们需要理解缓存在ORM系统中的分类:
- 按缓存对象分类
- 实体缓存:缓存单一实体对象,键为实体ID
- 关联缓存:缓存实体间的关联关系,如外键引用或集合
-
查询缓存:缓存查询结果,键为查询条件的规范化表示
-
按缓存级别分类
- 一级缓存(L1):事务内缓存,确保同一事务内数据一致性
- 二级缓存(L2):应用级缓存,在应用实例内共享
-
三级缓存(L3):分布式缓存,跨应用实例共享
-
按失效策略分类
- 时间驱动:基于固定时间或访问时间自动过期
- 事件驱动:基于数据变更事件主动失效
- 容量驱动:基于缓存容量限制的淘汰策略
在实际应用中,这些分类交织在一起,形成复杂的缓存矩阵,为不同场景提供差异化缓存策略。
真实世界的缓存挑战
考虑一个典型的电商商品详情页,其中包含: - 商品基本信息(名称、描述、图片) - 当前价格(可能受促销影响,频繁变化) - 库存状态(高频变化) - 商品评价(定期更新) - 相关推荐产品(基于复杂算法)
这些不同组件有着完全不同的缓存需求: - 商品基础信息:变更频率低,可长时间缓存 - 价格和库存:变更频率高,缓存时间短,一致性要求高 - 评价数据:读多写少,可采用延迟失效策略 - 推荐产品:计算密集型,适合结果缓存
在传统框架中,开发者需要为每种数据创建和维护独立的缓存策略,代码复杂度迅速增长。而在Jimmer中,这些差异化的需求可以通过声明式配置优雅地表达,同时保持代码简洁。
6.2.2 三级缓存架构:从孤岛到协同系统¶
缓存架构的演进
缓存系统的发展经历了几个关键阶段:
- 孤立缓存阶段 - 各个业务模块独立维护自己的缓存,导致缓存逻辑重复、一致性难以保障
- 统一缓存阶段 - 采用统一的缓存抽象,如Spring Cache,但缓存操作与业务逻辑混合
- 多级缓存阶段 - 引入多级缓存结构,但各级缓存之间协调复杂
- 智能协同阶段 - Jimmer代表的新一代缓存架构,实现缓存与事务、实体关系的智能协同
Jimmer的三级缓存设计汲取了CPU缓存层次结构的灵感,通过明确分层和优先级策略,在保证数据一致性的同时实现最优性能。
L1缓存:事务内确定性
L1缓存(事务缓存)是Jimmer缓存体系的第一道防线,具有以下特性:
- 作用域:单一事务内有效
- 存储位置:当前线程的事务上下文
- 自动管理:由Jimmer自动创建和释放,无需配置
- 强一致性:保证同一事务内的读取一致性
// L1缓存示例 - 同一事务内的读取一致性保证
@Transactional
public void updateProductPrice(String productId, BigDecimal newPrice) {
// 第一次查询 - 从数据库加载
Product product = sqlClient.findById(ProductFetcher.$.allScalarFields(), productId);
BigDecimal oldPrice = product.price();
// 更新价格
Product updated = ProductDraft.$.produce(product, draft -> {
draft.setPrice(newPrice);
});
sqlClient.save(updated);
// 再次查询同一产品 - 从L1缓存获取,看到更新后的价格
Product refreshed = sqlClient.findById(ProductFetcher.$.price(), productId);
// 记录价格变更日志
logPriceChange(productId, oldPrice, refreshed.price());
// 在整个事务中,保证数据一致性,不会出现第二次查询看到旧价格的情况
}
L1缓存的技术实现基于线程本地存储(ThreadLocal)的事务上下文,确保了事务内查询的高效与一致性。与Hibernate的一级缓存不同,Jimmer的L1缓存不会引入脏读问题,因为所有缓存的实体都是不可变的快照。
L2缓存:应用内共享
L2缓存(应用缓存)是应用实例内共享的缓存层,具有以下特性:
- 作用域:单一应用实例内所有事务共享
- 存储位置:应用内存(如Caffeine缓存)
- 可配置:支持细粒度的缓存策略配置
- 有条件一致性:通过事务集成确保应用内一致性
// 配置L2缓存 - 使用Caffeine作为缓存实现
@Bean
public JSqlClient sqlClient(DataSource dataSource) {
return JSqlClient.newBuilder()
.setDataSource(dataSource)
.setCacheFactory(
new CacheFactory() {
@Override
public <K, V> Cache<K, V> createObjectCache(ImmutableType type) {
// 为不同实体类型配置不同的缓存策略
if (type.getName().equals("org.ljma.jimmer.samples.entity.Product")) {
// 产品对象缓存 - 较短的过期时间,因为价格可能频繁变化
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats() // 启用统计
.build()
);
} else if (type.getName().equals("org.ljma.jimmer.samples.entity.Category")) {
// 类别对象缓存 - 较长的过期时间,因为类别不频繁更新
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofHours(1))
.build()
);
}
// 其他实体使用默认缓存配置
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(5_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build()
);
}
// 关联ID缓存配置
@Override
public <K, V> Cache<K, V> createAssociatedIdCache(ImmutableProp prop) {
// 为关联关系配置缓存
if (prop.getName().equals("category") &&
prop.getDeclaringType().getName().equals("org.ljma.jimmer.samples.entity.Product")) {
// 产品类别关系缓存
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build()
);
}
// 默认关联缓存配置
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(20_000)
.expireAfterWrite(Duration.ofMinutes(20))
.build()
);
}
// 其他缓存工厂方法...
}
)
.build();
}
L2缓存的一个关键特性是自动失效机制。当实体被修改时,Jimmer会自动使相关的缓存条目失效,确保后续查询能看到最新数据。这种自动失效机制极大地简化了开发复杂度。
我们可以通过测试用例直观地观察这一特性:
@Test
@DisplayName("测试缓存自动失效 - 实体更新后")
public void testCacheInvalidationAfterUpdate() {
// 第一次查询 - 加载并缓存
Product originalProduct = sqlClient.findById(ProductFetcher.$.allScalarFields(), PRODUCT_ID_1);
assertThat(originalProduct).isNotNull();
assertThat(originalProduct.name()).isEqualTo("智能手机");
BigDecimal originalPrice = originalProduct.price();
// 更新产品价格
BigDecimal newPrice = originalPrice.add(new BigDecimal("500.00"));
Product updatedDraft = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID_1);
draft.setPrice(newPrice);
});
sqlClient.save(updatedDraft);
// 再次查询 - 应获取新价格(缓存已自动失效)
Product updatedProduct = sqlClient.findById(ProductFetcher.$.price(), PRODUCT_ID_1);
assertThat(updatedProduct).isNotNull();
assertThat(updatedProduct.price()).isEqualTo(newPrice);
}
L3缓存:分布式共享
L3缓存(分布式缓存)是跨应用实例共享的缓存层,适用于集群环境,具有以下特性:
- 作用域:多个应用实例共享
- 存储位置:独立的缓存服务器(如Redis)
- 高可用性:支持集群和故障转移
- 全局一致性:通过事件通知机制保证集群内一致性
// 配置L3缓存 - 使用Redis作为分布式缓存
@Bean
public JSqlClient sqlClient(DataSource dataSource, StringRedisTemplate redisTemplate) {
return JSqlClient.newBuilder()
.setDataSource(dataSource)
.setCacheFactory(
new CacheFactory() {
@Override
public <K, V> Cache<K, V> createObjectCache(ImmutableType type) {
// 创建多级缓存,结合本地和分布式缓存的优势
return new MultiLevelCache<>(
// L2: 本地Caffeine缓存,低延迟
new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build()
),
// L3: Redis分布式缓存,集群共享
new RedisCache<>(
redisTemplate,
// 缓存键前缀,避免冲突
"entity:" + type.getName() + ":",
// 过期时间
Duration.ofMinutes(30)
)
);
}
// 其他缓存工厂方法...
}
)
.build();
}
在分布式环境中,L3缓存面临的最大挑战是跨节点的缓存一致性。Jimmer通过以下机制解决这一问题:
- 分布式缓存失效通知:当一个实例修改数据时,通过发布/订阅机制通知其他实例使相关缓存失效
- 乐观锁控制:使用乐观锁和版本控制机制避免并发更新冲突
- 定期刷新策略:对于热点数据,可采用定期刷新策略减少失效通知风暴
三级缓存协同工作流程
下图展示了Jimmer三级缓存的协同工作流程:
这种多级缓存结构确保了: 1. 最低的查询延迟(优先从最快的缓存层获取) 2. 最高的数据一致性(修改操作触发精确的缓存失效) 3. 最佳的资源利用(不同缓存层针对不同访问模式优化)
缓存命中分析
通过执行testCacheHitAndMiss测试,我们可以观察缓存的实际效果:
@Test
@DisplayName("测试缓存命中与未命中情况")
public void testCacheHitAndMiss() {
// 第一次查询 - 缓存未命中
QueryResult<Product> firstQuery = measureQueryTime(() ->
sqlClient.findById(ProductFetcher.$.name().price(), PRODUCT_ID_1)
);
Product product = firstQuery.getResult();
assertThat(product).isNotNull();
assertThat(product.name()).isEqualTo("智能手机");
// 第二次查询同一ID - 缓存命中
QueryResult<Product> secondQuery = measureQueryTime(() ->
sqlClient.findById(ProductFetcher.$.name().price(), PRODUCT_ID_1)
);
// 打印查询时间
System.out.println("First query time (cache miss): " + firstQuery.getExecutionTimeNs() + " ns");
System.out.println("Second query time (cache hit): " + secondQuery.getExecutionTimeNs() + " ns");
}
在实际测试中,第二次查询的执行时间通常只有第一次的1/10甚至更少,展示了缓存带来的显著性能提升。
6.2.3 缓存工厂与生命周期:精细化的控制艺术¶
在真实业务系统中,不同实体和不同业务场景对缓存的需求差异很大。例如,一个电商系统中:
- 商品类别信息(几乎不变)适合长期缓存
- 商品基本信息(偶尔变化)适合中期缓存
- 商品库存数据(频繁变化)可能不适合缓存或只适合极短期缓存
- 促销活动信息(定时变化)适合带有精确过期时间的缓存
Jimmer的缓存工厂(CacheFactory)提供了强大而灵活的机制,支持这种精细化的缓存控制,而不牺牲代码的简洁性。
缓存工厂:定制化缓存策略的核心
缓存工厂是Jimmer缓存系统的核心配置入口,它负责为不同类型的数据创建适合的缓存实例。在Spring环境中,典型的配置如下:
@Configuration
public class JimmerConfiguration {
@Bean
public JSqlClient sqlClient(DataSource dataSource) {
return JSqlClient.newBuilder()
.setDataSource(dataSource)
.setCacheFactory(createCacheFactory())
.build();
}
private CacheFactory createCacheFactory() {
return new CacheFactory() {
// 对象缓存 - 缓存完整实体
@Override
public <K, V> Cache<K, V> createObjectCache(ImmutableType type) {
String typeName = type.getName();
// 根据实体类型设置不同的缓存策略
if (typeName.endsWith("Product")) {
return createProductCache();
} else if (typeName.endsWith("Category")) {
return createCategoryCache();
} else if (typeName.endsWith("Inventory")) {
return createInventoryCache();
}
// 默认缓存策略
return createDefaultCache();
}
// 关联ID缓存 - 缓存多对一关联关系
@Override
public <K, V> Cache<K, V> createAssociatedIdCache(ImmutableProp prop) {
// 可以根据属性名称和拥有者类型定制策略
String propName = prop.getName();
String ownerType = prop.getDeclaringType().getName();
if (propName.equals("category") && ownerType.endsWith("Product")) {
// 产品-类别关联的特殊缓存策略
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(20_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build()
);
}
// 其它关联关系的默认策略
return createDefaultAssociationCache();
}
// 关联ID列表缓存 - 缓存一对多关联关系
@Override
public <K, V> Cache<K, V> createAssociatedIdListCache(ImmutableProp prop) {
String propName = prop.getName();
if (propName.equals("products")) {
// 类别-产品一对多关系缓存
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(5_000)
.expireAfterWrite(Duration.ofMinutes(15))
.build()
);
}
return createDefaultCollectionCache();
}
// 辅助方法 - 创建不同类型的缓存...
private <K, V> Cache<K, V> createProductCache() {
// 产品实体缓存 - 使用较短的过期时间
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build()
);
}
private <K, V> Cache<K, V> createCategoryCache() {
// 类别实体缓存 - 更长的过期时间,因为类别很少变化
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofHours(2))
.build()
);
}
private <K, V> Cache<K, V> createInventoryCache() {
// 库存实体 - 极短的过期时间,或返回null禁用缓存
return null; // 返回null表示不缓存此类型
}
// 默认缓存策略
private <K, V> Cache<K, V> createDefaultCache() {
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build()
);
}
// 其他辅助方法...
};
}
}
这种设计允许开发者基于实体类型和属性精确控制缓存行为,而无需修改业务代码。业务层代码可以保持干净:
@Service
public class ProductService {
@Autowired
private JSqlClient sqlClient;
public Product findProductById(String id) {
// 简洁的业务代码,缓存逻辑由Jimmer根据配置自动处理
return sqlClient.findById(
ProductFetcher.$.allScalarFields().category(
CategoryFetcher.$.name()
),
id
);
}
@Transactional
public Product updateProductPrice(String id, BigDecimal newPrice) {
// 更新逻辑,无需手动处理缓存
Product draft = ProductDraft.$.produce(draft -> {
draft.setId(id);
draft.setPrice(newPrice);
});
return sqlClient.save(draft).getModifiedEntity();
}
}
可替换的缓存实现
Jimmer支持多种缓存实现,可以根据项目需求选择:
- Caffeine:高性能的本地缓存,适合单实例应用
- Redis:分布式缓存,适合集群环境
- 自定义缓存:通过实现Cache接口支持任何缓存技术
下面是一个结合Redis和Caffeine的混合缓存示例:
// Redis缓存实现示例
public class RedisCache<K, V> implements Cache<K, V> {
private final StringRedisTemplate redisTemplate;
private final String keyPrefix;
private final Duration expiration;
private final Function<V, String> serializer;
private final Function<String, V> deserializer;
public RedisCache(
StringRedisTemplate redisTemplate,
String keyPrefix,
Duration expiration,
Function<V, String> serializer,
Function<String, V> deserializer) {
this.redisTemplate = redisTemplate;
this.keyPrefix = keyPrefix;
this.expiration = expiration;
this.serializer = serializer;
this.deserializer = deserializer;
}
@Override
public V get(K key) {
String redisKey = keyPrefix + key.toString();
String value = redisTemplate.opsForValue().get(redisKey);
return value != null ? deserializer.apply(value) : null;
}
@Override
public void put(K key, V value) {
String redisKey = keyPrefix + key.toString();
String serialized = serializer.apply(value);
redisTemplate.opsForValue().set(redisKey, serialized, expiration);
}
@Override
public void remove(K key) {
String redisKey = keyPrefix + key.toString();
redisTemplate.delete(redisKey);
}
// 其他实现方法...
}
// 多级缓存示例 - 组合本地和分布式缓存
public class TwoLevelCache<K, V> implements Cache<K, V> {
private final Cache<K, V> localCache; // Caffeine
private final Cache<K, V> remoteCache; // Redis
public TwoLevelCache(Cache<K, V> localCache, Cache<K, V> remoteCache) {
this.localCache = localCache;
this.remoteCache = remoteCache;
}
@Override
public V get(K key) {
// 先查本地缓存
V value = localCache.get(key);
if (value != null) {
return value;
}
// 本地未命中,查远程缓存
value = remoteCache.get(key);
if (value != null) {
// 将远程缓存结果回填到本地缓存
localCache.put(key, value);
}
return value;
}
@Override
public void put(K key, V value) {
// 同时更新本地和远程缓存
localCache.put(key, value);
remoteCache.put(key, value);
}
@Override
public void remove(K key) {
// 同时从本地和远程缓存移除
localCache.remove(key);
remoteCache.remove(key);
}
// 其他方法实现...
}
缓存生命周期管理
在企业级系统中,缓存生命周期管理至关重要,涉及多个维度:
- 时间维度
- 创建时间:缓存条目何时被首次放入缓存
- 访问时间:缓存条目最后一次被读取的时间
- 修改时间:缓存条目最后一次被更新的时间
-
过期时间:缓存条目何时自动过期
-
事件维度
- 手动触发:通过API显式使缓存失效
- 数据变更:实体被修改时自动触发相关缓存失效
- 依赖变更:关联实体变更导致的缓存失效
- 容量限制:达到缓存容量上限触发淘汰
Jimmer提供了完善的缓存生命周期管理机制,确保缓存数据的新鲜度和准确性。
测试案例:自动缓存失效
下面的测试用例展示了Jimmer的自动缓存失效机制如何工作:
@Test
@DisplayName("测试关联实体缓存与级联更新")
public void testAssociatedEntityCaching() {
// 第一次查询,加载产品及其类别
QueryResult<Product> firstQuery = measureQueryTime(() ->
sqlClient.findById(
ProductFetcher.$.allScalarFields().category(
CategoryFetcher.$.name().description()
),
PRODUCT_ID_1
)
);
Product product = firstQuery.getResult();
assertThat(product).isNotNull();
assertThat(product.name()).isEqualTo("智能手机");
assertThat(product.category()).isNotNull();
assertThat(product.category().name()).isEqualTo("电子设备");
// 第二次查询,应从缓存读取
QueryResult<Product> secondQuery = measureQueryTime(() ->
sqlClient.findById(
ProductFetcher.$.allScalarFields().category(
CategoryFetcher.$.name().description()
),
PRODUCT_ID_1
)
);
// 验证结果一致
assertThat(secondQuery.getResult().category().name()).isEqualTo("电子设备");
// 更新类别名称
Category updatedCategory = CategoryDraft.$.produce(draft -> {
draft.setId(CATEGORY_ID_1);
draft.setName("高端电子设备");
});
sqlClient.save(updatedCategory);
// 再次查询,关联的类别缓存应该已失效
Product productAfterCategoryUpdate = sqlClient.findById(
ProductFetcher.$.category(CategoryFetcher.$.name()),
PRODUCT_ID_1
);
// 验证能看到更新后的类别名称
assertThat(productAfterCategoryUpdate.category().name()).isEqualTo("高端电子设备");
}
这个测试揭示了Jimmer缓存的一个强大特性:关联感知的自动失效。当更新类别实体时,系统不仅使该类别的缓存失效,还会自动使引用此类别的产品缓存失效,确保任何引用更新后的类别的查询都能获取最新数据。
缓存监控与统计
在生产环境中,监控缓存性能对于系统调优至关重要。Jimmer支持缓存统计收集:
// 启用缓存统计
Cache<Object, Object> productCache = new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats() // 启用统计
.build()
);
// 定期输出缓存统计信息
@Scheduled(fixedRate = 300_000) // 每5分钟
public void logCacheStatistics() {
com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache =
((CaffeineCache<Object, Object>)productCache).getInternalCache();
CacheStats stats = caffeineCache.stats();
log.info("产品缓存统计: 命中率={}%, 平均加载时间={}ms, 驱逐次数={}, 缓存大小={}",
String.format("%.2f", stats.hitRate() * 100),
String.format("%.2f", stats.averageLoadPenalty() / 1_000_000.0),
stats.evictionCount(),
caffeineCache.estimatedSize()
);
}
通过监控缓存命中率和性能指标,开发团队可以根据实际使用模式优化缓存策略,如调整缓存大小、过期时间或预加载热点数据。
6.2.4 缓存技术的灵活选择:从单机到云原生¶
在现代应用架构中,缓存技术的选择不再是一个简单的技术决策,而是需要考虑应用部署模式、可伸缩性需求和业务特性的全方位战略决策。Jimmer的设计理念是提供最大的灵活性,支持从单实例应用到大规模分布式系统的各种场景。
缓存技术选择矩阵
根据系统规模和部署模式,可采用不同的缓存技术组合:
| 应用规模 | 部署模式 | 推荐缓存技术 | 优势 | 注意事项 |
|---|---|---|---|---|
| 小型应用 | 单实例 | Caffeine (L2) | 低延迟、低资源消耗、无额外依赖 | 受单机内存限制 |
| 中型应用 | 少量实例 | Caffeine (L2) + Redis (L3) | 平衡性能与共享能力,支持水平扩展 | 需要维护Redis,增加部署复杂度 |
| 大型应用 | 多实例集群 | 分层缓存 + Redis Cluster | 高可用性、大容量、可弹性扩展 | 需要精细化缓存管理策略 |
| 超大规模 | 云原生 | 多级分片缓存 + 专用缓存服务 | 极高吞吐量、区域化优化、弹性扩缩容 | 复杂的一致性考量,需要专门的缓存团队 |
单实例应用:Caffeine的极致性能
对于单实例应用,Caffeine通常是最佳选择,它提供了卓越的性能和灵活的配置选项:
// Caffeine缓存配置示例
@Bean
public JSqlClient sqlClient(DataSource dataSource) {
return JSqlClient.newBuilder()
.setDataSource(dataSource)
.setCacheFactory(new CacheFactory() {
@Override
public <K, V> Cache<K, V> createObjectCache(ImmutableType type) {
return new CaffeineCache<>(
Caffeine.newBuilder()
// Window TinyLFU回收策略,均衡考虑访问频率和时间
.maximumSize(50_000)
// 写入后30分钟过期
.expireAfterWrite(Duration.ofMinutes(30))
// 访问后60分钟过期(如果没有再次访问)
.expireAfterAccess(Duration.ofMinutes(60))
// 软引用可以在内存压力下自动释放
.softValues()
// 启用性能统计
.recordStats()
.build()
);
}
// 其他缓存工厂方法...
})
.build();
}
Caffeine的高性能源于其先进的缓存算法和数据结构: - Window TinyLFU驱逐策略:比传统LRU/LFU更智能的驱逐算法,结合频率和时间因素 - 高并发优化:采用了无锁并发数据结构,减少线程竞争 - 预读优化:智能预读和批量加载机制,减少缓存穿透
根据Caffeine官方基准测试,在高并发读取场景下,其性能超过Guava Cache约40%,比EhCache高出近80%。
分布式应用:Redis的弹性与共享
对于分布式应用,Redis是最受欢迎的缓存技术之一,它提供了强大的分布式特性:
// Redis缓存配置示例
@Bean
public JSqlClient sqlClient(DataSource dataSource, RedisConnectionFactory redisConnectionFactory) {
// 创建RedisTemplate用于Redis交互
RedisTemplate<String, byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new ByteArrayRedisSerializer());
redisTemplate.afterPropertiesSet();
// 创建用于实体序列化的Jackson对象映射器
ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return JSqlClient.newBuilder()
.setDataSource(dataSource)
.setCacheFactory(new CacheFactory() {
@Override
public <K, V> Cache<K, V> createObjectCache(ImmutableType type) {
// Redis缓存实现
return new RedisCache<>(
redisTemplate,
"entity:" + type.getJavaClass().getSimpleName() + ":",
// 指定缓存过期时间
Duration.ofMinutes(30),
// 序列化器 - 将实体转换为字节数组
value -> {
try {
return objectMapper.writeValueAsBytes(value);
} catch (Exception e) {
throw new RuntimeException("序列化失败", e);
}
},
// 反序列化器 - 将字节数组转换回实体对象
bytes -> {
try {
@SuppressWarnings("unchecked")
Class<V> valueType = (Class<V>)type.getJavaClass();
return objectMapper.readValue(bytes, valueType);
} catch (Exception e) {
throw new RuntimeException("反序列化失败", e);
}
}
);
}
// 其他缓存工厂方法...
})
.build();
}
Redis作为分布式缓存带来多项关键优势: - 数据共享:所有应用实例共享同一个缓存,避免数据重复和不一致 - 原子操作:内置丰富的原子操作支持,简化分布式锁和计数器 - 发布/订阅:支持消息发布/订阅,可用于缓存失效通知 - 持久化:可选的持久化机制,防止缓存冷启动问题
混合缓存架构:多级缓存策略
对于性能和可用性要求极高的系统,多级缓存架构通常是最佳选择:
// 多级缓存实现示例
public class MultiTierCache<K, V> implements Cache<K, V> {
private final Cache<K, V> localCache; // 本地Caffeine缓存
private final Cache<K, V> remoteCache; // 远程Redis缓存
private final String cacheType; // 用于日志和指标
public MultiTierCache(
Cache<K, V> localCache,
Cache<K, V> remoteCache,
String cacheType) {
this.localCache = localCache;
this.remoteCache = remoteCache;
this.cacheType = cacheType;
}
@Override
public V get(K key) {
// 1. 尝试从本地缓存获取
V value = localCache.get(key);
if (value != null) {
logCacheHit("local");
return value;
}
// 2. 本地未命中,尝试从远程缓存获取
value = remoteCache.get(key);
if (value != null) {
// 将远程缓存值回填到本地缓存
localCache.put(key, value);
logCacheHit("remote");
} else {
logCacheMiss();
}
return value;
}
@Override
public void put(K key, V value) {
// 同时更新本地和远程缓存
localCache.put(key, value);
remoteCache.put(key, value);
}
@Override
public void remove(K key) {
// 同时从本地和远程缓存删除
localCache.remove(key);
remoteCache.remove(key);
}
// 日志和指标收集方法
private void logCacheHit(String tier) {
if (log.isDebugEnabled()) {
log.debug("{}缓存命中: tier={}", cacheType, tier);
}
// 这里可以增加指标收集,如增加特定tier的命中计数
}
private void logCacheMiss() {
if (log.isDebugEnabled()) {
log.debug("{}缓存未命中", cacheType);
}
// 这里可以增加指标收集,如增加未命中计数
}
}
多级缓存的优势在大型分布式系统中尤为明显: - 优化读路径:大多数读操作可在本地缓存完成,显著降低延迟 - 减轻远程缓存负担:远程缓存仅承担部分查询负载,提升整体可伸缩性 - 故障弹性:即使远程缓存暂时不可用,本地缓存仍可提供服务 - 有效处理热点数据:热点数据在本地缓存,减少网络开销
自定义序列化策略:优化性能与空间
在分布式缓存中,序列化策略对性能和空间效率有重大影响。Jimmer支持自定义序列化:
// 高性能的Protobuf序列化示例
public class ProtobufRedisCache<K, V> implements Cache<K, V> {
private final RedisTemplate<String, byte[]> redisTemplate;
private final String keyPrefix;
private final Duration ttl;
private final Class<V> valueType;
private final SchemaRegistry schemaRegistry; // Protobuf schema注册表
// 构造函数省略...
@Override
public V get(K key) {
String redisKey = keyPrefix + key.toString();
byte[] bytes = redisTemplate.opsForValue().get(redisKey);
if (bytes == null) {
return null;
}
try {
// 使用Protobuf反序列化,比JSON更高效
return schemaRegistry.deserialize(bytes, valueType);
} catch (Exception e) {
log.error("缓存反序列化失败: key={}", redisKey, e);
// 反序列化失败时删除损坏的缓存项
redisTemplate.delete(redisKey);
return null;
}
}
@Override
public void put(K key, V value) {
String redisKey = keyPrefix + key.toString();
try {
// 使用Protobuf序列化,生成更小的二进制数据
byte[] bytes = schemaRegistry.serialize(value);
redisTemplate.opsForValue().set(redisKey, bytes, ttl);
} catch (Exception e) {
log.error("缓存序列化失败: key={}", redisKey, e);
}
}
// 其他方法省略...
}
针对不同场景,选择合适的序列化策略至关重要:
| 序列化方式 | 优势 | 适用场景 |
|---|---|---|
| JSON | 可读性好,广泛支持 | 开发/调试环境,数据结构频繁变化 |
| Protobuf | 体积小,性能高 | 生产环境,性能敏感系统 |
| 自定义二进制 | 极致性能和空间优化 | 特定领域优化,如时间序列数据 |
应用级缓存委派:场景驱动选择
在复杂系统中,不同实体类型可能需要不同的缓存实现。Jimmer支持基于类型的缓存委派:
// 场景驱动的缓存选择示例
@Bean
public CacheFactory cacheFactory(
RedisConnectionFactory redisConnectionFactory) {
// 创建不同类型的缓存实现
Cache<Object, Object> readHeavyCache = createRedisCache(redisConnectionFactory, Duration.ofHours(1));
Cache<Object, Object> volatileCache = createCaffeineCache(Duration.ofMinutes(5));
Cache<Object, Object> hybridCache = createMultiTierCache(redisConnectionFactory);
Cache<Object, Object> noCache = null; // null表示不缓存
return new CacheFactory() {
@Override
public <K, V> Cache<K, V> createObjectCache(ImmutableType type) {
String typeName = type.getName();
// 根据实体类型和访问模式选择最合适的缓存策略
if (typeName.endsWith("Category") || typeName.endsWith("Brand")) {
// 读多写少,长期缓存
log.info("为{}配置读密集型缓存", typeName);
return (Cache<K, V>)readHeavyCache;
} else if (typeName.endsWith("Inventory") || typeName.endsWith("Price")) {
// 频繁变化,不缓存
log.info("{}数据波动性大,禁用缓存", typeName);
return (Cache<K, V>)noCache;
} else if (typeName.endsWith("Product") || typeName.endsWith("Order")) {
// 核心业务实体,使用混合缓存
log.info("为核心业务实体{}配置混合缓存", typeName);
return (Cache<K, V>)hybridCache;
} else {
// 默认使用短期本地缓存
log.info("为{}配置默认缓存", typeName);
return (Cache<K, V>)volatileCache;
}
}
// 其他工厂方法...
};
}
// 创建各种缓存实现的辅助方法...
这种基于业务场景的缓存策略选择,可以在不修改业务代码的情况下精确控制系统性能和数据新鲜度,实现最佳的性能与一致性平衡。
6.2.5 缓存的内存占用管理:平衡性能与资源¶
在企业级应用中,缓存是一把双刃剑:它能显著提升性能,但也可能导致内存占用过高,甚至引发内存溢出(OutOfMemoryError)。对于处理大量数据的系统,如何在有限的内存资源内实现最优的缓存效果,是一门精细的平衡艺术。
缓存膨胀的潜在风险
考虑一个电商平台的产品目录系统,假设: - 系统有100万种产品 - 每个产品实体平均占用5KB内存 - 每个产品还有多个关联对象(类别、品牌、规格等)
如果不加控制地缓存所有产品,可能导致: - 产品实体缓存:100万 × 5KB = 5GB - 加上关联对象:可能超过10GB
在8GB堆内存的JVM中,这将占用大部分可用内存,留给业务逻辑的空间严重不足,极易造成GC问题或OOM异常。
三维度的内存控制策略
Jimmer提供了三个维度的内存控制策略,让开发者能根据业务特性和系统资源进行精细调整:
- 容量控制:限制缓存条目数量或总内存占用
- 过期策略:基于时间的自动失效机制
- 驱逐算法:当达到容量上限时如何选择淘汰对象
容量控制:明确的边界
容量控制为缓存设置了明确的上限,防止无限膨胀:
// 基于条目数量的容量控制
Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存10,000个对象
.build();
// 基于权重的容量控制
Caffeine.newBuilder()
.maximumWeight(100_000_000) // 最大总权重100MB
.weigher((key, value) -> {
// 自定义权重计算,例如基于对象的估计内存占用
if (value instanceof Product) {
Product product = (Product) value;
// 考虑产品名称、描述等字段长度
return 1000 + product.name().length() * 2 +
(product.description() != null ? product.description().length() : 0);
}
return 1000; // 默认权重
})
.build();
在实际应用中,如何确定合适的缓存容量?这需要考虑多个因素:
- 可用内存:通常建议缓存占用总堆内存的30%以内
- 数据访问模式:遵循28/80法则,识别热点数据
- 数据更新频率:频繁更新的数据集合不适合大容量缓存
- 业务重要性:核心业务实体可以分配更多缓存资源
以下是一个基于实体类型动态分配缓存容量的示例:
@Override
public <K, V> Cache<K, V> createObjectCache(ImmutableType type) {
String typeName = type.getName();
long maxSize;
if (typeName.endsWith("Product")) {
// 核心业务实体,分配较大缓存空间
maxSize = 50_000;
} else if (typeName.endsWith("Category") || typeName.endsWith("Brand")) {
// 数量少且稳定的实体,可完全缓存
maxSize = 1_000;
} else if (typeName.endsWith("Inventory")) {
// 高频变更数据,分配最小缓存或不缓存
return null; // 不缓存
} else if (typeName.endsWith("Order")) {
// 重要但数量庞大的实体,适度缓存
maxSize = 10_000;
} else {
// 其他实体默认配置
maxSize = 5_000;
}
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(Duration.ofMinutes(30))
.build()
);
}
过期策略:时间维度的控制
过期策略从时间维度控制缓存生命周期,防止缓存数据过时:
// 几种典型的过期策略
// 1. 写入后过期:适用于定期更新的数据
Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(30))
.build();
// 2. 访问后过期:适用于访问频率低的数据
Caffeine.newBuilder()
.expireAfterAccess(Duration.ofHours(2))
.build();
// 3. 自定义过期:基于数据特性动态设置过期时间
Caffeine.newBuilder()
.expireAfter(new Expiry<K, V>() {
@Override
public long expireAfterCreate(K key, V value, long currentTime) {
// 根据值的特性设置过期时间
if (value instanceof Product) {
Product product = (Product) value;
if (product.isHotSelling()) {
// 热销产品更频繁更新,缓存时间短
return TimeUnit.MINUTES.toNanos(5);
} else if (product.isEndOfLife()) {
// 停产产品几乎不更新,缓存时间长
return TimeUnit.DAYS.toNanos(1);
}
}
// 默认30分钟
return TimeUnit.MINUTES.toNanos(30);
}
@Override
public long expireAfterUpdate(K key, V value, long currentTime, long currentDuration) {
// 更新操作后的过期计算
return expireAfterCreate(key, value, currentTime);
}
@Override
public long expireAfterRead(K key, V value, long currentTime, long currentDuration) {
// 读取操作后的过期计算
return currentDuration; // 保持原有过期时间不变
}
})
.build();
在选择过期策略时,需要权衡数据新鲜度与缓存效率: - 过期时间太短:缓存效果差,频繁回源查询 - 过期时间太长:数据可能不够新鲜,影响业务逻辑
企业实践表明,最佳做法是根据业务特性分层设置过期时间:
| 数据类型 | 更新频率 | 推荐过期策略 | 典型过期时间 |
|---|---|---|---|
| 参考数据(如类别、地区) | 极低 | 长期缓存+手动触发 | 数小时到数天 |
| 核心业务数据(如产品) | 中等 | 写入后过期 | 5-30分钟 |
| 活动数据(如促销) | 定时 | 固定时间点过期 | 与活动时间同步 |
| 高频变动数据(如库存) | 极高 | 极短过期或不缓存 | 秒级或不缓存 |
| 用户特定数据 | 个性化 | 访问后过期 | 30分钟左右 |
驱逐算法:智能的容量管理
当缓存达到容量上限时,驱逐算法决定哪些条目应被淘汰。不同的驱逐策略适用于不同的访问模式:
- LRU (最近最少使用)
- 特点:淘汰最长时间未被访问的条目
- 优势:实现简单,适应最新访问模式
- 劣势:对于周期性访问的数据表现不佳
-
适用:访问模式随时间变化的数据
-
LFU (最不经常使用)
- 特点:淘汰访问频率最低的条目
- 优势:保留热点数据,更好的命中率
- 劣势:对新加入缓存的数据不友好
-
适用:访问频率相对稳定的数据
-
FIFO (先进先出)
- 特点:淘汰最早加入缓存的条目
- 优势:实现最简单
- 劣势:不考虑访问模式,命中率较低
-
适用:所有数据价值相近的场景
-
W-TinyLFU (窗口化频率过滤)
- 特点:Caffeine默认算法,结合频率和时间因素
- 优势:适应性强,平衡新数据和热点数据
- 适用:大多数业务场景,特别是混合访问模式
在实际应用中,W-TinyLFU通常是最佳选择,因为它综合考虑了时间和频率因素。当然,对于特定业务场景,可以考虑自定义淘汰策略。
内存敏感缓存设计
对于内存资源受限的系统,以下高级技术可以进一步优化缓存内存使用:
-
软引用/弱引用:允许在内存压力下自动释放缓存
-
数据压缩:对缓存值进行压缩存储
// 使用压缩的缓存包装器示例 public class CompressedCache<K, V> implements Cache<K, V> { private final Cache<K, byte[]> delegate; @Override public V get(K key) { byte[] compressed = delegate.get(key); if (compressed == null) { return null; } return deserializeAndDecompress(compressed); } @Override public void put(K key, V value) { byte[] compressed = compressAndSerialize(value); delegate.put(key, compressed); } // 压缩和反压缩方法... } -
部分缓存:只缓存对象的部分关键字段
-
分层存储:热点数据在内存,冷数据在磁盘或远程缓存
性能与内存平衡的最佳实践
实际项目中,我们建议遵循以下最佳实践来平衡缓存性能与内存占用:
- 监控缓存指标
- 命中率:通常应保持在80%以上
- 驱逐率:频繁驱逐表明容量可能不足
-
内存占用:观察随时间变化的趋势
-
分级缓存策略
- 关键业务对象使用更大的缓存空间
- 根据访问频率和更新模式调整过期时间
-
使用多级缓存,热数据在内存,冷数据在更低成本的存储
-
定期调优
- 基于真实业务数据进行容量规划
- 定期分析缓存效率,调整参数
-
关注业务高峰期的内存使用情况
-
安全机制
- 使用软引用作为最后的安全网
- 为关键组件设置单独的缓存空间
- 实现优雅降级:缓存失效时能直接查询数据源
6.2.6 小结¶
本节介绍了Jimmer的缓存架构设计,包括多层缓存结构、缓存工厂与生命周期、缓存技术选择以及内存占用管理。通过测试用例,我们验证了Jimmer缓存系统的有效性和自动化程度。
Jimmer的缓存系统具有以下特点:
- 多层架构:L1/L2/L3三级缓存结构提供了从事务内到分布式环境的全面缓存支持
- 自动同步:与事务系统紧密集成,确保缓存与数据库状态一致
- 灵活配置:支持多种缓存技术和策略,满足不同场景需求
- 性能优化:精细的缓存粒度和高效的缓存操作显著提升系统性能
6.3 缓存类型与应用场景¶
高性能的企业应用系统离不开合理的缓存设计。Jimmer作为一个现代ORM框架,提供了多种缓存机制,每种都针对不同的应用场景进行了优化。本节将详细介绍Jimmer支持的四种缓存类型,它们分别是:对象缓存、关联缓存、计算属性缓存和多视图缓存。我们将通过实际测试案例来展示每种缓存的特点、应用场景以及性能影响。
6.3.1 对象缓存:提升单体查询性能¶
- 对象缓存的概念
对象缓存是ORM框架中最基本的缓存类型,它缓存单个实体对象的所有数据。当应用程序多次查询同一个实体对象时,框架可以直接从缓存中获取数据,而不需要再次访问数据库,从而显著提升查询性能。
Jimmer的对象缓存具有以下特点:
- 按ID缓存:每个实体对象都通过其主键ID进行缓存
- 类型隔离:不同类型的实体对象存储在独立的缓存空间中
- 自动失效:当实体被修改时,缓存会自动失效
-
可配置性:可以为不同的实体类型配置不同的缓存策略
-
测试对象缓存
让我们通过一个测试用例来验证对象缓存的有效性。我们将查询同一个商品两次,第一次会从数据库加载数据,而第二次应该直接从缓存中获取:
@Test
@DisplayName("测试对象缓存 - 单实体查询性能优化")
public void testObjectCache() {
// 1. 第一次查询 - 缓存未命中
QueryResult<Product> firstQuery = measureQueryTime(() ->
sqlClient.findById(ProductFetcher.$.allScalarFields(), PRODUCT_ID_1)
);
Product product = firstQuery.getResult();
assertThat(product).isNotNull();
assertThat(product.name()).isEqualTo("智能手机");
// 2. 第二次查询 - 应该使用缓存
QueryResult<Product> secondQuery = measureQueryTime(() ->
sqlClient.findById(ProductFetcher.$.allScalarFields(), PRODUCT_ID_1)
);
Product cachedProduct = secondQuery.getResult();
assertThat(cachedProduct).isNotNull();
assertThat(cachedProduct.name()).isEqualTo("智能手机");
// 3. 修改实体对象
Product updatedDraft = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID_1);
draft.setPrice(new BigDecimal("4999.99"));
});
sqlClient.save(updatedDraft);
// 4. 第三次查询 - 缓存应已更新
Product updatedProduct = sqlClient.findById(
ProductFetcher.$.allScalarFields(),
PRODUCT_ID_1
);
assertThat(updatedProduct.price()).isEqualTo(new BigDecimal("4999.99"));
}
该测试用例验证了对象缓存的三个关键特性:
- 缓存命中:第二次查询同一商品时,数据应从缓存中获取
- 性能提升:第二次查询的执行时间应明显少于第一次查询
-
缓存失效:当商品价格被修改后,缓存应自动更新
-
对象缓存的应用场景
对象缓存特别适合以下应用场景:
- 读多写少的数据:如商品分类、地区信息等基础数据
- 高频访问的热点数据:如热门商品、首页推荐等
- 相对稳定的数据:变更频率低的实体对象
- ID直接查询为主的场景:通过主键直接查询单个对象的场景
在这类场景中,对象缓存可以大幅降低数据库访问频率,提高应用响应速度,减轻数据库负担。
- 对象缓存的实现机制
Jimmer的对象缓存基于以下机制实现:
- 缓存键设计:使用实体类型+ID作为缓存键
- 缓存值设计:缓存实体完整的不可变对象
- 失效策略:
- 当执行更新或删除操作时,自动失效对应的缓存项
- 提供基于时间的过期策略(TTL)
- 支持基于容量的淘汰策略(LRU)
6.3.2 关联缓存:优化实体间关系查询¶
- 关联缓存的概念
关联缓存是针对实体之间关联关系的缓存机制。它缓存实体间的关联数据,如一对多、多对一关系,从而减少连接查询的需求。关联缓存尤其适合处理复杂对象图的场景,可以显著提高查询性能。
Jimmer的关联缓存具有以下特点:
- 双向缓存:同时缓存正向和反向关联
- 按关联ID缓存:缓存关联对象的ID集合,而非完整对象
- 级联失效:修改一端会导致关联另一端的缓存同时失效
-
延迟加载优化:能显著提升懒加载性能
-
测试关联缓存
我们通过以下测试用例验证关联缓存的有效性,包括多对一关联和一对多关联的缓存效果:
@Test
@DisplayName("测试关联缓存 - 优化实体间关系查询")
public void testAssociationCache() {
// 1. 第一次查询,测试多对一关联(产品→类别)
QueryResult<Product> manyToOneFirstQuery = measureQueryTime(() ->
sqlClient.findById(
ProductFetcher.$.allScalarFields().category(CategoryFetcher.$.allScalarFields()),
PRODUCT_ID_1
)
);
Product product = manyToOneFirstQuery.getResult();
assertThat(product.category()).isNotNull();
assertThat(product.category().name()).isEqualTo("电子产品");
// 2. 第二次查询,应该利用关联缓存
QueryResult<Product> manyToOneSecondQuery = measureQueryTime(() ->
sqlClient.findById(
ProductFetcher.$.allScalarFields().category(CategoryFetcher.$.allScalarFields()),
PRODUCT_ID_1
)
);
// 3. 测试一对多关联(类别→产品列表)
QueryResult<Category> oneToManyFirstQuery = measureQueryTime(() ->
sqlClient.findById(
CategoryFetcher.$.allScalarFields().products(ProductFetcher.$.allScalarFields()),
CATEGORY_ID_1
)
);
Category category = oneToManyFirstQuery.getResult();
assertThat(category.products()).hasSize(2);
// 4. 第二次查询,应该利用关联缓存
QueryResult<Category> oneToManySecondQuery = measureQueryTime(() ->
sqlClient.findById(
CategoryFetcher.$.allScalarFields().products(ProductFetcher.$.allScalarFields()),
CATEGORY_ID_1
)
);
// 5. 添加新产品到类别中
Product newProductDraft = ProductDraft.$.produce(draft -> {
draft.setId("ct-prod-new");
draft.setName("新产品");
draft.setPrice(new BigDecimal("1999.99"));
draft.setStock(50);
draft.setActive(true);
draft.setCategoryId(CATEGORY_ID_1);
draft.setCreatedTime(LocalDateTime.now());
});
sqlClient.save(newProductDraft);
// 6. 再次查询类别,验证产品列表已更新
Category updatedCategory = sqlClient.findById(
CategoryFetcher.$.allScalarFields().products(ProductFetcher.$.allScalarFields()),
CATEGORY_ID_1
);
assertThat(updatedCategory.products()).hasSize(3);
}
这个测试用例验证了关联缓存的几个重要特性:
- 多对一关联缓存:产品对象的类别关联被缓存,第二次查询能利用缓存
- 一对多关联缓存:类别对象的产品列表被缓存,第二次查询能利用缓存
-
关联缓存自动失效:当添加新产品到类别时,类别的产品列表缓存自动更新
-
关联缓存的应用场景
关联缓存特别适用于以下场景:
- 复杂对象图查询:需要同时加载多个相关联实体的场景
- 树形或层级数据:如组织架构、地区层级等树形结构
- 固定关联的数据:关联关系变动较少的场景
- 多级导航场景:如从A导航到B,再导航到C的多级导航查询
在电商系统中,典型的应用如商品和分类的关联、订单和订单项的关联等,通过关联缓存可以显著减少数据库连接查询,提高查询性能。
- 关联缓存的实现机制
Jimmer的关联缓存基于以下机制实现:
- 关联ID缓存:不缓存完整对象,而是缓存关联ID
- 多对一关联:缓存外键ID到目标ID的映射
-
一对多关联:缓存主键ID到关联ID集合的映射
-
两级缓存机制:
- 一级缓存:关联ID缓存(外键→目标ID或主键→关联ID集合)
-
二级缓存:对象缓存(ID→完整对象)
-
联动更新机制:
- 修改外键:自动失效多对一和一对多两个方向的缓存
- 删除对象:自动失效所有相关联的缓存
6.3.3 计算属性缓存:降低复杂计算开销¶
- 计算属性缓存的概念
计算属性缓存用于缓存复杂计算的结果,特别是聚合函数(如SUM、AVG、COUNT)或复杂SQL表达式的计算结果。通过缓存这些计算结果,可以避免重复执行昂贵的计算操作,显著提高应用性能。
Jimmer的计算属性缓存具有以下特点:
- 结果集缓存:缓存整个查询的结果集
- 按查询条件缓存:相同的SQL查询条件会返回缓存的结果
- 精确失效:当影响计算结果的数据变更时,缓存自动失效
-
适用于聚合查询:特别适合统计分析类的查询
-
测试计算属性缓存
我们通过以下测试用例验证计算属性缓存的有效性,测试场景是计算某个类别下所有产品的总价值:
@Test
@DisplayName("测试计算属性缓存 - 减少复杂计算开销")
public void testCalculatedPropertyCache() {
// 1. 第一次查询,计算所有产品的总价值
ProductTable pt = ProductTable.$;
QueryResult<List<Product>> firstQuery = measureQueryTime(() ->
sqlClient.createQuery(pt)
.where(pt.categoryId().eq(CATEGORY_ID_1))
.select(pt)
.execute()
);
List<Product> firstResult = firstQuery.getResult();
assertThat(firstResult).isNotEmpty();
// 计算初始总价值
BigDecimal initialTotalPrice = calculateTotalPrice(firstResult);
// 2. 第二次查询,应该利用缓存
QueryResult<List<Product>> secondQuery = measureQueryTime(() ->
sqlClient.createQuery(pt)
.where(pt.categoryId().eq(CATEGORY_ID_1))
.select(pt)
.execute()
);
// 验证两次结果一致
List<Product> secondResult = secondQuery.getResult();
assertThat(secondResult.size()).isEqualTo(firstResult.size());
BigDecimal secondTotalPrice = calculateTotalPrice(secondResult);
assertThat(secondTotalPrice).isEqualTo(initialTotalPrice);
// 3. 添加新产品,影响计算结果
Product newProductDraft = ProductDraft.$.produce(draft -> {
draft.setId("ct-prod-calc");
draft.setName("计算测试产品");
draft.setPrice(new BigDecimal("10000.00"));
draft.setStock(10);
draft.setActive(true);
draft.setCategoryId(CATEGORY_ID_1);
draft.setCreatedTime(LocalDateTime.now());
});
sqlClient.save(newProductDraft);
// 4. 再次查询,缓存应该已失效
List<Product> updatedResult = sqlClient.createQuery(pt)
.where(pt.categoryId().eq(CATEGORY_ID_1))
.select(pt)
.execute();
// 验证总价值已更新
BigDecimal newTotalPrice = calculateTotalPrice(updatedResult);
assertThat(newTotalPrice).isGreaterThan(initialTotalPrice);
}
/**
* 计算产品列表的总价值
*/
private BigDecimal calculateTotalPrice(List<Product> products) {
return products.stream()
.map(Product::price)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
这个测试用例验证了计算属性缓存的关键特性:
- 计算结果缓存:第二次执行相同的查询时,应直接返回缓存的结果
- 缓存自动失效:当添加新产品(影响计算结果)时,缓存应自动失效
-
计算结果更新:失效后再次查询,应返回更新后的计算结果
-
计算属性缓存的应用场景
计算属性缓存特别适用于以下场景:
- 统计分析查询:如销售额统计、库存汇总等
- 报表生成:需要执行复杂聚合计算的报表查询
- 排行榜数据:如商品销量排行、用户活跃度排名等
- 复杂条件筛选:涉及多表连接和聚合的复杂查询
在一个大型电商系统中,典型的应用包括销售报表、库存报表、订单统计等。这些查询通常计算开销大,但查询结果变更频率相对较低,非常适合使用计算属性缓存。
- 计算属性缓存的实现机制
Jimmer的计算属性缓存基于以下机制实现:
- 缓存键设计:
- 由SQL语句、参数值和结果类型共同构成
-
对相同的查询条件返回缓存结果
-
缓存粒度:
- 缓存整个查询的结果集,而非单个记录
-
支持分页查询结果的缓存
-
缓存失效策略:
- 表级别失效:当表中的数据发生变化时,相关查询缓存自动失效
- 细粒度失效:只影响受影响行相关的缓存,而非全表缓存
6.3.4 多视图缓存:满足不同数据视角需求¶
- 多视图缓存的概念
多视图缓存是Jimmer独有的一种高级缓存机制,它允许为同一实体对象的不同"视图"(即不同字段集合)维护独立的缓存。这种机制特别适合前端不同场景需要展示同一对象不同详细程度的情况。
Jimmer的多视图缓存具有以下特点:
- 视图隔离:不同视图(Fetcher)的结果拥有独立的缓存空间
- 按需加载:只缓存查询中实际需要的字段
- 粒度优化:减少缓存数据冗余,提高缓存利用率
-
视图协同:不同视图的缓存可以协同工作,最大化复用已缓存数据
-
测试多视图缓存
我们通过以下测试用例验证多视图缓存的有效性,测试场景是同一产品的不同视图查询:
@Test
@DisplayName("测试多视图缓存 - 应对不同用户的数据视角")
public void testMultiViewCache() {
// 1. 使用详细视图查询产品 (管理员视图)
QueryResult<Product> adminViewQuery = measureQueryTime(() ->
sqlClient.findById(
ProductFetcher.$.allScalarFields().category(CategoryFetcher.$.allScalarFields()),
PRODUCT_ID_1
)
);
Product adminProduct = adminViewQuery.getResult();
assertThat(adminProduct).isNotNull();
assertThat(adminProduct.category()).isNotNull();
// 2. 使用简化视图查询产品 (客户视图)
QueryResult<Product> customerViewQuery = measureQueryTime(() ->
sqlClient.findById(
ProductFetcher.$.name().price().description(),
PRODUCT_ID_1
)
);
Product customerProduct = customerViewQuery.getResult();
assertThat(customerProduct).isNotNull();
// 3. 再次使用管理员视图查询,应该使用缓存
QueryResult<Product> adminViewCachedQuery = measureQueryTime(() ->
sqlClient.findById(
ProductFetcher.$.allScalarFields().category(CategoryFetcher.$.allScalarFields()),
PRODUCT_ID_1
)
);
// 4. 再次使用客户视图查询,应该使用缓存
QueryResult<Product> customerViewCachedQuery = measureQueryTime(() ->
sqlClient.findById(
ProductFetcher.$.name().price().description(),
PRODUCT_ID_1
)
);
// 5. 修改产品信息
Product updatedDraft = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID_1);
draft.setName("更新后的智能手机");
draft.setPrice(new BigDecimal("5499.99"));
});
sqlClient.save(updatedDraft);
// 6. 验证两种视图都能获取到最新数据
Product updatedAdminProduct = sqlClient.findById(
ProductFetcher.$.allScalarFields().category(CategoryFetcher.$.allScalarFields()),
PRODUCT_ID_1
);
Product updatedCustomerProduct = sqlClient.findById(
ProductFetcher.$.name().price().description(),
PRODUCT_ID_1
);
assertThat(updatedAdminProduct.name()).isEqualTo("更新后的智能手机");
assertThat(updatedCustomerProduct.name()).isEqualTo("更新后的智能手机");
}
这个测试用例验证了多视图缓存的几个关键特性:
- 视图独立缓存:不同视图(管理员视图和客户视图)各自拥有独立的缓存
- 缓存复用:重复查询同一视图时,应利用对应的视图缓存
-
缓存同步更新:当产品信息更新时,所有相关视图的缓存都应同步更新
-
多视图缓存的应用场景
多视图缓存特别适用于以下场景:
- 不同用户角色:如管理员需要完整信息,普通用户只需基本信息
- 不同展示场景:如列表页显示简略信息,详情页显示完整信息
- 渐进式加载:先加载基本信息,再按需加载详细信息
- 权限过滤视图:根据用户权限显示不同字段的场景
在企业应用中,典型的应用包括:
- 电商平台:商品在列表页、详情页、管理后台等不同场景展示不同程度的详细信息
- CRM系统:客户信息在不同模块或不同权限用户视角下展示不同信息
-
内容管理系统:内容在编辑模式和预览模式下需要不同视图
-
多视图缓存的实现机制
Jimmer的多视图缓存基于以下机制实现:
- 视图表达:
- 使用Fetcher对象精确表达需要查询的字段和关联
-
Fetcher本身作为缓存键的一部分,确保不同视图有独立缓存
-
视图协同:
- 属性共享:不同视图查询的相同属性可以共享底层缓存数据
-
视图合并:简单视图可以复用详细视图的已缓存数据
-
视图缓存策略:
- 频繁使用的视图可以设置更长的过期时间
- 不同视图可以配置不同的缓存策略
- 支持视图粒度的缓存统计和监控
6.3.5 缓存类型的选择与组合应用¶
在实际应用中,不同的缓存类型往往需要组合使用,以实现最佳的性能优化效果。下面,我们将讨论不同场景下如何选择和组合使用这四种缓存类型。
- 选择合适的缓存类型
选择缓存类型时,主要考虑以下因素:
- 数据访问模式:
- 单实体频繁访问 → 对象缓存
- 关联导航查询为主 → 关联缓存
- 统计分析查询为主 → 计算属性缓存
-
不同场景不同字段 → 多视图缓存
-
数据变更频率:
- 高频变更数据 → 短期缓存或不缓存
- 中等频率变更 → 中等过期时间的缓存
-
低频变更数据 → 长期缓存
-
数据一致性要求:
- 强一致性要求 → 使用事务级缓存或禁用缓存
- 最终一致性允许 → 设置适当的过期时间
-
允许一定滞后 → 使用主动失效策略
-
四种缓存的组合应用
在复杂业务系统中,通常会组合使用多种缓存类型:
- 电商商品系统:
- 对象缓存:缓存热门商品基本信息
- 关联缓存:缓存商品与类别、标签的关联
- 计算属性缓存:缓存类别商品总数、平均价格等统计信息
-
多视图缓存:列表页简略视图、详情页完整视图、管理后台视图
-
订单管理系统:
- 对象缓存:缓存最近创建的订单
- 关联缓存:缓存订单与订单项的关联
- 计算属性缓存:缓存每日订单总额、数量等统计信息
-
多视图缓存:普通用户订单视图、客服人员订单视图
-
用户权限系统:
- 对象缓存:缓存用户基本信息
- 关联缓存:缓存用户与角色、权限的关联
- 计算属性缓存:缓存权限统计信息
-
多视图缓存:不同场景下的用户信息视图
-
缓存策略的动态调整
在生产环境中,缓存策略往往需要根据实际情况进行动态调整:
- 监控缓存命中率:
- 低命中率的缓存可能需要调整缓存键设计
-
高命中率但性能提升不明显的缓存可能配置过大
-
分析缓存失效原因:
- 频繁失效的缓存可能不适合当前业务场景
-
失效范围过大可能需要调整缓存粒度
-
动态调整缓存容量:
- 根据系统负载和内存使用情况调整缓存大小
- 不同时段可能需要不同的缓存配置
总结¶
本节我们详细介绍了Jimmer支持的四种缓存类型:对象缓存、关联缓存、计算属性缓存和多视图缓存。每种缓存类型都有其特定的适用场景和优化目标。通过合理组合使用这些缓存机制,可以大幅提升应用系统的性能。
对象缓存适合优化单实体查询,关联缓存适合优化实体间关系导航,计算属性缓存适合优化统计分析查询,多视图缓存则适合优化不同场景下的数据展示需求。在实际应用中,应根据具体业务场景和性能需求,灵活选择和组合使用这些缓存类型。
不过,缓存虽然能显著提升性能,但也引入了数据一致性的挑战。在下一节中,我们将深入讨论如何通过合理的缓存配置策略,在性能和一致性之间取得平衡。
6.3.6 缓存策略配置与优化¶
在了解了Jimmer支持的四种缓存类型后,下一个关键问题是如何为每种缓存类型配置适当的缓存策略。合理的缓存策略配置可以最大化缓存的性能优势,同时避免缓存带来的数据一致性问题。本节将详细介绍如何为不同类型的缓存设计合适的策略。
- 缓存策略的核心参数
无论是哪种类型的缓存,都涉及以下核心配置参数:
- 容量 (Capacity):
- 缓存可存储的最大数据量
- 通常以项数或内存大小表示
-
超出容量会触发缓存淘汰
-
过期时间 (TTL, Time-To-Live):
- 缓存数据的最长有效期
- 过期后数据会被自动清除
-
可设置不同粒度的过期策略
-
淘汰策略 (Eviction Policy):
- LRU (Least Recently Used):淘汰最久未使用的数据
- LFU (Least Frequently Used):淘汰最少使用的数据
-
FIFO (First In First Out):淘汰最早加入的数据
-
刷新策略 (Refresh Policy):
- 被动刷新:访问时发现过期才刷新
- 主动刷新:后台定时任务主动刷新热点数据
-
按需预热:系统启动时预加载重要数据
-
对象缓存策略配置
对象缓存作为最基础的缓存类型,其策略配置直接影响整体缓存性能:
@Bean
public JSqlClient sqlClient(DataSource dataSource) {
return JSqlClient.newBuilder()
.setDataSource(dataSource)
.setCacheFactory(
new CacheFactory() {
@Override
public <K, V> Cache<K, V> createObjectCache(ImmutableType type) {
// 根据实体类型设置不同的缓存策略
if (type.getName().equals("org.ljma.jimmer.samples.entity.Product")) {
// 商品对象缓存 - 较短的过期时间,因为价格可能频繁变化
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats() // 启用统计
.build()
);
} else if (type.getName().equals("org.ljma.jimmer.samples.entity.Category")) {
// 类别对象缓存 - 较长的过期时间,因为类别不频繁更新
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofHours(1))
.build()
);
}
// 其他实体使用默认缓存配置
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(5_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build()
);
}
// 其他类型缓存配置...
}
)
.build();
}
-
对象缓存策略最佳实践
-
根据数据变更频率设置过期时间:
- 高频变更数据(如商品价格):短过期时间(5-15分钟)
- 中频变更数据(如商品详情):中等过期时间(30分钟-2小时)
-
低频变更数据(如类别信息):长过期时间(数小时或一天)
-
根据数据量和访问模式设置缓存容量:
- 热点访问数据(如热门商品):较大的缓存容量
- 长尾数据:较小的缓存容量,依赖淘汰机制
-
基础数据(如系统配置):完全缓存,不设容量限制
-
启用缓存统计:
- 监控缓存命中率
- 分析缓存效率
-
根据统计数据动态调整策略
-
关联缓存策略配置
关联缓存策略通常需要考虑关联的类型和复杂度:
@Override
public <K, V> Cache<K, V> createAssociationCache(
ImmutableProp prop,
boolean reversed
) {
String entityName = reversed ?
prop.getDeclaringType().getName() :
prop.getTargetType().getName();
// 根据关联属性类型设置不同的缓存策略
if (prop.getName().equals("products") && !reversed) {
// 类别->产品的一对多关联
// 缓存容量大,过期时间短,因为产品频繁变动
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(20_000)
.expireAfterWrite(Duration.ofMinutes(15))
.build()
);
} else if (prop.getName().equals("category") && !reversed) {
// 产品->类别的多对一关联
// 缓存容量小,过期时间长,因为类别变动少
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(5_000)
.expireAfterWrite(Duration.ofHours(2))
.build()
);
}
// 默认关联缓存配置
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build()
);
}
-
关联缓存策略最佳实践
-
根据关联类型设置不同策略:
- 多对一关联(如商品->类别):较大容量,中等过期时间
- 一对多关联(如类别->商品):中等容量,较短过期时间
-
多对多关联(如商品<->标签):考虑分开正反向关联的策略
-
考虑级联关系:
- 父子关系数据:设置相同或相近的过期时间,保持一致性
-
单向依赖关系:被依赖方可以设置较长过期时间
-
根据关联的基数调整容量:
- 高基数关联(一个对象关联大量对象):设置更大的缓存容量
-
低基数关联(一个对象关联少量对象):可以设置较小的缓存容量
-
计算属性缓存策略配置
计算属性缓存需要特别考虑计算成本和数据更新频率:
@Override
public <K, V> Cache<K, V> createCalculatedCache(
ImmutableProp prop
) {
// 根据计算属性类型设置不同的缓存策略
if (prop.getName().equals("totalSales")) {
// 销售总额计算缓存 - 计算成本高,但需要较新的数据
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build()
);
} else if (prop.getName().equals("averageRating")) {
// 平均评分缓存 - 计算成本中等,可以接受一定滞后
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(5_000)
.expireAfterWrite(Duration.ofHours(1))
.build()
);
}
// 默认计算缓存配置
return new CaffeineCache<>(
Caffeine.newBuilder()
.maximumSize(2_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build()
);
}
-
计算属性缓存策略最佳实践
-
根据计算成本设置缓存策略:
- 高成本计算(如复杂聚合查询):较长过期时间,最大化复用
- 中等成本计算:中等过期时间,平衡新鲜度和性能
-
低成本计算:较短过期时间或考虑不缓存
-
根据数据更新频率调整过期时间:
- 频繁更新的基础数据:较短过期时间,确保计算结果准确性
-
稳定的基础数据:较长过期时间,减少不必要的重新计算
-
考虑业务容忍度:
- 实时报表:短过期时间,确保数据最新
-
分析统计报表:长过期时间,优化性能
-
多视图缓存策略配置
多视图缓存需要考虑不同视图的使用场景和重要程度:
// 默认Jimmer不需要为多视图缓存单独配置,它使用对象缓存的策略
// 但我们可以通过自定义FetcherFactory实现不同视图的差异化配置
@Bean
public FetcherFactory fetcherFactory() {
return new FetcherFactory() {
@Override
public <E> Fetcher<E> createFetcher(Class<E> entityType, Consumer<FetcherBuilder<E>> block) {
// 创建基本Fetcher
Fetcher<E> fetcher = Fetchers.newFetcher(entityType, block);
// 分析Fetcher特征,为特定视图添加元数据
if (entityType == Product.class) {
// 为不同的产品视图设置特定的缓存键前缀
// 这可以影响缓存的组织方式
if (isListView(fetcher)) {
return fetcher.withCacheKeyPrefix("list_view");
} else if (isDetailView(fetcher)) {
return fetcher.withCacheKeyPrefix("detail_view");
} else if (isAdminView(fetcher)) {
return fetcher.withCacheKeyPrefix("admin_view");
}
}
return fetcher;
}
// 辅助方法判断视图类型
private <E> boolean isListView(Fetcher<E> fetcher) {
// 实现视图特征判断逻辑
return fetcher.getFieldMap().size() <= 3 &&
fetcher.getFieldMap().containsKey("name") &&
fetcher.getFieldMap().containsKey("price");
}
private <E> boolean isDetailView(Fetcher<E> fetcher) {
// 实现视图特征判断逻辑
return fetcher.getFieldMap().size() > 3 &&
fetcher.getFieldMap().containsKey("description");
}
private <E> boolean isAdminView(Fetcher<E> fetcher) {
// 实现视图特征判断逻辑
return fetcher.getFieldMap().containsKey("stock") &&
fetcher.getFieldMap().containsKey("active");
}
};
}
-
多视图缓存策略最佳实践
-
根据视图用途设置缓存策略:
- 高频访问视图(如列表页视图):较大缓存容量,较短过期时间
- 详情视图:中等缓存容量,中等过期时间
-
管理后台视图:较小缓存容量,短过期时间(保证及时更新)
-
考虑视图共享数据的情况:
- 多个视图共享基础字段:可以设置更长的过期时间
-
视图特有的字段:可以设置单独的过期策略
-
根据用户角色差异化:
- 普通用户视图:优先考虑性能,较长过期时间
-
管理员视图:优先考虑实时性,较短过期时间
-
缓存监控与动态调整
除了初始配置外,缓存策略还应该能够根据实际运行情况动态调整:
@Component
public class CacheMonitor {
private final Map<String, CaffeineCacheStats> cacheStatsMap = new ConcurrentHashMap<>();
// 注册缓存以进行监控
public void registerCache(String name, Cache<?, ?> cache) {
if (cache instanceof CaffeineCache) {
CaffeineCache<?, ?> caffeineCache = (CaffeineCache<?, ?>) cache;
cacheStatsMap.put(name, new CaffeineCacheStats(caffeineCache));
}
}
// 定期收集并分析缓存统计信息
@Scheduled(fixedRate = 60_000) // 每分钟执行一次
public void collectStats() {
for (Map.Entry<String, CaffeineCacheStats> entry : cacheStatsMap.entrySet()) {
String cacheName = entry.getKey();
CaffeineCacheStats stats = entry.getValue();
// 收集并记录缓存统计信息
double hitRate = stats.getHitRate();
long evictionCount = stats.getEvictionCount();
long size = stats.getSize();
// 记录指标,可以输出到日志或监控系统
log.info("Cache '{}' stats: hit rate = {}, evictions = {}, size = {}",
cacheName, hitRate, evictionCount, size);
// 根据统计信息动态调整缓存参数
if (hitRate < 0.5 && size > 1000) {
// 命中率低且缓存较大,可能需要减小缓存大小
log.warn("Low hit rate for cache '{}', consider reducing size", cacheName);
} else if (evictionCount > 1000 && hitRate > 0.9) {
// 淘汰频繁且命中率高,可能需要增加缓存大小
log.warn("High eviction with good hit rate for cache '{}', consider increasing size",
cacheName);
}
}
}
// 缓存统计信息包装类
private static class CaffeineCacheStats {
private final CaffeineCache<?, ?> cache;
public CaffeineCacheStats(CaffeineCache<?, ?> cache) {
this.cache = cache;
}
public double getHitRate() {
com.github.benmanes.caffeine.cache.stats.CacheStats stats =
cache.getCaffeine().stats();
return stats.hitRate();
}
public long getEvictionCount() {
return cache.getCaffeine().stats().evictionCount();
}
public long getSize() {
return cache.getCaffeine().estimatedSize();
}
}
}
-
缓存监控最佳实践
-
关键指标监控:
- 命中率:理想值应在80%以上
- 淘汰频率:大量淘汰表明缓存容量可能不足
-
平均加载时间:反映缓存未命中时的性能损失
-
动态调整策略:
- 低命中率问题:考虑调整缓存键设计或增加缓存容量
- 高淘汰率问题:考虑增加缓存容量或细化缓存粒度
-
过期导致的频繁加载:考虑延长过期时间或实现异步预热
-
定期缓存维护:
- 系统低峰期主动清理过期数据
- 为热点数据实现预热机制
-
根据业务周期调整缓存策略(如促销期间扩大商品缓存)
-
总结
合理的缓存策略配置是充分发挥Jimmer缓存优势的关键。对象缓存、关联缓存、计算属性缓存和多视图缓存各有其适用的配置策略。通过根据数据特性、访问模式和业务需求灵活配置这些缓存策略,可以实现最佳的性能优化效果。
同时,缓存不是静态的,而是需要持续监控和动态调整的系统组件。通过建立完善的缓存监控机制,及时发现缓存问题并做出调整,可以确保缓存系统的健康运行。
6.4 事务与缓存的协同机制¶
在实际应用中,事务和缓存是两个密切相关但又相互制约的技术。事务保证数据操作的一致性和完整性,而缓存则提升性能和响应速度。当二者结合使用时,如何在保证数据一致性的同时获得最佳性能,是系统设计的重要挑战。本节将探讨Jimmer中事务与缓存的协同机制,帮助开发者理解如何在项目中正确配置和使用这两项核心功能。
6.4.1 事务生命周期中的缓存操作¶
事务从开始到结束的生命周期中,缓存的行为遵循一定规律。了解这些规律,有助于我们预测和控制缓存状态。
- 事务中的缓存读写行为
让我们通过一个测试用例,观察事务生命周期中缓存的读写行为:
@Test
@DisplayName("测试事务生命周期中的缓存操作")
public void testCacheOperationsInTransactionLifecycle() {
// 测试数据标识
final String CATEGORY_ID = "tcc-001";
final String PRODUCT_ID = "tcc-002";
try {
// 1. 在事务外部准备测试数据
Category category = createTestCategory(CATEGORY_ID, "事务测试类别");
Product product = createTestProduct(PRODUCT_ID, "事务测试商品",
new BigDecimal("999.99"), CATEGORY_ID);
// 2. 使用编程式事务进行测试
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED);
// 第一阶段:开始事务并在事务内缓存产品数据
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 验证当前处于事务中
boolean isInTransaction = TransactionSynchronizationManager.isActualTransactionActive();
assertThat(isInTransaction).isTrue();
// 第一次查询 - 缓存预热
Product product1 = sqlClient.findById(
ProductFetcher.$.allScalarFields(), PRODUCT_ID
);
// 修改商品数据
Product updatedDraft = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID);
draft.setPrice(new BigDecimal("1099.99"));
});
sqlClient.save(updatedDraft);
// 事务内立即查询,应该能看到更新后的价格
Product productInTransaction = sqlClient.findById(
ProductFetcher.$.allScalarFields(),
PRODUCT_ID
);
assertThat(productInTransaction.price()).isEqualTo(new BigDecimal("1099.99"));
// 提交事务
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
fail("事务执行失败", e);
}
} finally {
// 清理测试数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
从上述代码中,我们可以观察到以下关键点:
- 事务内的缓存读取:在事务内第一次查询商品时,Jimmer会从数据库加载数据并放入缓存。
- 事务内的数据变更:当在事务内更新商品价格后,再次查询同一商品,能立即看到更新后的价格。
- 事务提交与缓存同步:事务提交后,缓存中的数据会与数据库保持一致。
Jimmer在事务中实现了"读你所写"的一致性,确保事务内对缓存的读取能反映事务内的写入操作,无需重新从数据库加载。这种机制对于需要在同一事务中读取刚写入数据的场景非常有用,如保存实体后立即返回完整视图。
- 事务提交与回滚对缓存的影响
事务的提交和回滚对缓存有不同的影响。我们继续看测试用例:
// 3. 事务提交后,验证缓存是否更新
Product productAfterCommit = sqlClient.findById(
ProductFetcher.$.allScalarFields(),
PRODUCT_ID
);
assertThat(productAfterCommit.price()).isEqualTo(new BigDecimal("1099.99"));
// 4. 启动新事务,再次修改价格
status = transactionManager.getTransaction(def);
try {
// 修改商品价格为1199.99
Product updatedDraft = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID);
draft.setPrice(new BigDecimal("1199.99"));
});
sqlClient.save(updatedDraft);
// 意外回滚事务
transactionManager.rollback(status);
} catch (Exception e) {
transactionManager.rollback(status);
fail("事务执行失败", e);
}
// 5. 事务回滚后,验证缓存中的数据
Product productAfterRollback = sqlClient.findById(
ProductFetcher.$.allScalarFields(),
PRODUCT_ID
);
// 在trigger-type=TRANSACTION_ONLY配置下,事务回滚不会自动使缓存回滚
// 因此缓存中仍然保留修改后的价格1199.99,而不是原始价格1099.99
assertThat(productAfterRollback.price()).isEqualTo(new BigDecimal("1199.99"));
通过这段代码,可以观察到两个重要特性:
- 事务提交后的缓存状态:事务提交后,缓存会反映提交的更改,后续查询将从缓存中获取更新后的数据。
- 事务回滚的缓存行为:在默认配置(
trigger-type: TRANSACTION_ONLY)下,事务回滚不会自动使缓存恢复到回滚前的状态。这意味着事务回滚后,缓存可能包含已被回滚的数据。
这是一个重要的设计决策,开发者需要清楚了解:Jimmer默认优先考虑性能而非缓存的事务一致性。如果应用对缓存事务一致性有较高要求,需要调整配置或实现自定义缓存处理逻辑。
6.4.2 写入事务后的缓存一致性保障¶
在分布式系统中,数据写入后缓存的一致性是一个核心挑战。Jimmer提供了精确的缓存失效机制,确保数据修改后缓存能及时更新。
- 实体与关联缓存的自动同步
以下测试用例展示了Jimmer如何处理实体与关联缓存的同步:
@Test
@DisplayName("测试写入事务后的缓存一致性保障")
@Transactional
public void testCacheConsistencyAfterWriteTransaction() {
// 测试数据标识
final String CATEGORY_ID = "tcc-003";
final String PRODUCT_ID = "tcc-004";
// 1. 准备测试数据
Category category = createTestCategory(CATEGORY_ID, "缓存一致性测试类别");
Product product = createTestProduct(PRODUCT_ID, "缓存一致性测试商品",
new BigDecimal("2999.99"), CATEGORY_ID);
// 2. 第一次查询,使用完整的fetcher包含关联数据
Product firstProduct = sqlClient.findById(
ProductFetcher.$.allScalarFields().category(
CategoryFetcher.$.allScalarFields()
),
PRODUCT_ID
);
assertThat(firstProduct.category().name()).isEqualTo("缓存一致性测试类别");
// 3. 修改类别名称
Category updatedCategory = CategoryDraft.$.produce(draft -> {
draft.setId(CATEGORY_ID);
draft.setName("已修改的测试类别");
});
sqlClient.save(updatedCategory);
// 4. 再次查询产品及其关联的类别
Product secondProduct = sqlClient.findById(
ProductFetcher.$.allScalarFields().category(
CategoryFetcher.$.allScalarFields()
),
PRODUCT_ID
);
// 验证关联的类别名称已更新在缓存中
assertThat(secondProduct.category().name()).isEqualTo("已修改的测试类别");
}
从这段代码中,我们可以看到:
- 关联对象的缓存更新:当修改类别名称后,再次查询产品时,关联的类别信息自动反映了更新。Jimmer能智能地使相关缓存条目失效,确保获取最新的关联数据。
-
缓存关系的精确处理:Jimmer不仅处理直接修改的实体缓存,还会处理与之关联的缓存条目,保持关联数据的一致性。
-
实体关系变更的缓存处理
当实体之间的关系发生变化时,Jimmer如何处理缓存呢?我们继续看测试用例:
// 5. 修改商品价格和关联的类别
String NEW_CATEGORY_ID = "tcc-005";
createTestCategory(NEW_CATEGORY_ID, "新关联类别");
Product updatedProduct = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID);
draft.setPrice(new BigDecimal("3599.99"));
draft.setCategoryId(NEW_CATEGORY_ID);
});
sqlClient.save(updatedProduct);
// 6. 查询商品,验证关联和价格都已更新
Product thirdProduct = sqlClient.findById(
ProductFetcher.$.allScalarFields().category(
CategoryFetcher.$.allScalarFields()
),
PRODUCT_ID
);
assertThat(thirdProduct.price()).isEqualTo(new BigDecimal("3599.99"));
assertThat(thirdProduct.category().id()).isEqualTo(NEW_CATEGORY_ID);
assertThat(thirdProduct.category().name()).isEqualTo("新关联类别");
此处我们可以观察到:
- 关联关系变更与缓存:当产品关联的类别从一个变为另一个时,缓存能正确反映这种关系变化。
- 多字段同时更新:即使同时更新了商品价格和关联的类别,缓存也能准确保持所有更改。
Jimmer的这种精确缓存更新机制,大幅降低了开发者管理缓存一致性的负担,同时保持了系统性能。
6.4.3 多实例环境中的事务与缓存同步¶
在微服务或集群环境中,多个应用实例共享同一数据库但各自维护独立缓存。这种情况下,一个实例的事务提交如何影响其他实例的缓存,是一个重要问题。
- 模拟多实例环境
以下测试用例模拟了两个不同应用实例同时操作同一数据的场景:
@Test
@DisplayName("测试多实例环境中的事务与缓存同步")
public void testTransactionAndCacheSynchronizationInMultiInstance() throws Exception {
// 测试数据标识
final String CATEGORY_ID = "tcc-006";
final String PRODUCT_ID = "tcc-007";
try {
// 1. 准备测试数据
Category category = createTestCategory(CATEGORY_ID, "多实例测试类别");
Product product = createTestProduct(PRODUCT_ID, "多实例测试商品",
new BigDecimal("1999.99"), CATEGORY_ID);
// 2. 模拟两个不同的应用实例同时操作
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch readyLatch = new CountDownLatch(2); // 确保两个"实例"都准备好
CountDownLatch startLatch = new CountDownLatch(1); // 控制同时开始
// 存储测试结果
AtomicReference<BigDecimal> instance1Price = new AtomicReference<>();
AtomicReference<BigDecimal> instance2Price = new AtomicReference<>();
AtomicBoolean instance1Success = new AtomicBoolean(false);
AtomicBoolean instance2Success = new AtomicBoolean(false);
// 模拟实例1操作
Future<?> instance1Future = executor.submit(() -> {
try {
// 准备就绪
readyLatch.countDown();
// 等待统一开始信号
startLatch.await();
// 执行事务
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 修改商品价格
Product updatedDraft = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID);
draft.setPrice(new BigDecimal("2099.99"));
});
sqlClient.save(updatedDraft);
// 查询价格并保存到结果
Product product1 = sqlClient.findById(ProductFetcher.$.price(), PRODUCT_ID);
instance1Price.set(product1.price());
// 提交事务
transactionManager.commit(status);
instance1Success.set(true);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
} catch (Exception e) {
e.printStackTrace();
}
});
// 模拟实例2操作,延迟100ms执行,确保实例1先完成
Future<?> instance2Future = executor.submit(() -> {
try {
// 准备就绪
readyLatch.countDown();
// 等待统一开始信号
startLatch.await();
// 故意延迟一下,确保实例1先执行
Thread.sleep(100);
// 执行事务
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 查询最新价格
Product product2 = sqlClient.findById(ProductFetcher.$.price(), PRODUCT_ID);
instance2Price.set(product2.price());
// 提交事务
transactionManager.commit(status);
instance2Success.set(true);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
} catch (Exception e) {
e.printStackTrace();
}
});
// 等待两个"实例"都准备好
readyLatch.await();
// 发出开始信号
startLatch.countDown();
// 等待两个任务完成
instance1Future.get(5, TimeUnit.SECONDS);
instance2Future.get(5, TimeUnit.SECONDS);
// 关闭线程池
executor.shutdown();
// 3. 验证结果
assertThat(instance1Success.get()).isTrue();
assertThat(instance2Success.get()).isTrue();
// 实例1应该看到自己更新的价格
assertThat(instance1Price.get()).isEqualTo(new BigDecimal("2099.99"));
// 实例2应该能看到实例1更新后的价格(缓存同步生效)
assertThat(instance2Price.get()).isEqualTo(new BigDecimal("2099.99"));
// 再次查询确认最终结果
Product finalProduct = sqlClient.findById(ProductFetcher.$.price(), PRODUCT_ID);
assertThat(finalProduct.price()).isEqualTo(new BigDecimal("2099.99"));
} finally {
// 清理测试数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
通过这个测试,我们可以观察到:
- 缓存的实例间同步:当实例1修改数据并提交事务后,实例2在查询同一数据时能够看到更新后的值。
-
分布式环境中的缓存一致性:Jimmer确保在分布式环境中,缓存能够与数据库保持一致,避免实例间的数据不一致问题。
-
缓存同步的实现机制
Jimmer通过以下机制实现多实例环境中的缓存同步:
- 数据库作为一致性仲裁者:所有实例通过同一数据库进行协调,确保数据的最终一致性。
- 缓存的时间有效性:通过设置缓存的过期时间,限制缓存数据的有效期,减少不一致窗口。
- 集中式缓存选项:对于一致性要求极高的场景,可以使用Redis等集中式缓存,所有实例共享同一缓存。
在实际应用中,根据业务对数据一致性的要求,可以选择不同的缓存策略和同步机制。
6.4.4 乐观锁与缓存版本控制¶
在并发环境中,乐观锁是一种常用的并发控制机制,而在缓存层面,版本控制也是保障数据一致性的重要手段。
- 乐观锁与缓存的结合使用
@Test
@DisplayName("测试乐观锁与缓存版本控制")
public void testOptimisticLockingWithCacheVersioning() {
// 测试数据标识
final String CATEGORY_ID = "tcc-008";
final String PRODUCT_ID = "tcc-009";
try {
// 1. 准备测试数据(这里我们通过修改时间模拟版本控制)
Category category = createTestCategory(CATEGORY_ID, "版本控制测试类别");
Product product = createTestProduct(PRODUCT_ID, "版本控制测试商品",
new BigDecimal("5999.99"), CATEGORY_ID);
// 2. 查询产品并缓存
Product originalProduct = sqlClient.findById(ProductFetcher.$.allScalarFields(), PRODUCT_ID);
LocalDateTime originalTimestamp = originalProduct.createdTime();
// 3. 模拟并发事务,使用修改时间作为版本控制
// 第一个事务:更新商品价格
Product firstUpdate = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID);
draft.setPrice(new BigDecimal("6499.99"));
draft.setCreatedTime(originalTimestamp); // 保持创建时间不变
draft.setModifiedTime(LocalDateTime.now()); // 更新修改时间
});
sqlClient.save(firstUpdate);
// 查询更新后的商品
Product updatedProduct = sqlClient.findById(ProductFetcher.$.allScalarFields(), PRODUCT_ID);
assertThat(updatedProduct.price()).isEqualTo(new BigDecimal("6499.99"));
assertThat(updatedProduct.modifiedTime()).isNotNull();
// 4. 验证缓存是否正确更新
Product cachedProduct = sqlClient.findById(ProductFetcher.$.allScalarFields(), PRODUCT_ID);
assertThat(cachedProduct.price()).isEqualTo(new BigDecimal("6499.99"));
} finally {
// 清理测试数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
在实际应用中,可以通过以下方式增强版本控制:
- 使用@Version注解:为实体添加版本字段,使用
@Version注解标记,让Jimmer自动处理乐观锁。 - 为缓存添加版本信息:将版本信息作为缓存键的一部分,确保不同版本的实体缓存不会冲突。
- 基于事件的缓存更新:通过实体事件监听器,在版本变更时触发缓存更新,保持缓存与数据库的一致性。
通过这些机制,可以有效解决多线程和分布式环境下的数据竞争问题,同时保持缓存的一致性。
6.4.5 事务回滚时的缓存处理¶
前面我们已经看到,在默认配置下,事务回滚不会自动使缓存恢复到原始状态。下面我们进一步探讨这个行为及其处理策略。
- 事务回滚的缓存状态
@Test
@DisplayName("测试事务回滚时的缓存处理")
public void testCacheHandlingOnTransactionRollback() {
// 测试数据标识
final String CATEGORY_ID = "tcc-010";
final String PRODUCT_ID = "tcc-011";
try {
// 1. 准备测试数据
Category category = createTestCategory(CATEGORY_ID, "回滚测试类别");
Product product = createTestProduct(PRODUCT_ID, "回滚测试商品",
new BigDecimal("7999.99"), CATEGORY_ID);
// 2. 第一次查询,将数据加载到缓存
Product originalProduct = sqlClient.findById(ProductFetcher.$.allScalarFields(), PRODUCT_ID);
assertThat(originalProduct.price()).isEqualTo(new BigDecimal("7999.99"));
// 3. 创建事务并执行更新,然后回滚
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 更新商品价格
Product updatedDraft = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID);
draft.setPrice(new BigDecimal("8599.99"));
});
sqlClient.save(updatedDraft);
// 在事务内查询,应该能看到更新后的价格
Product productInTransaction = sqlClient.findById(
ProductFetcher.$.allScalarFields(),
PRODUCT_ID
);
assertThat(productInTransaction.price()).isEqualTo(new BigDecimal("8599.99"));
// 故意回滚事务
transactionManager.rollback(status);
} catch (Exception e) {
transactionManager.rollback(status);
fail("事务执行失败", e);
}
// 4. 事务回滚后查询,在trigger-type=TRANSACTION_ONLY配置下
// 事务回滚不会使缓存自动恢复到原始状态
Product productAfterRollback = sqlClient.findById(
ProductFetcher.$.allScalarFields(),
PRODUCT_ID
);
// 验证价格仍然是更新后的价格,而不是原始价格
assertThat(productAfterRollback.price()).isEqualTo(new BigDecimal("8599.99"));
} finally {
// 清理测试数据
cleanupTestData(PRODUCT_ID, CATEGORY_ID);
}
}
这个测试揭示了一个重要特性:在默认配置下,事务回滚不会自动清除或回滚缓存中的数据。这可能导致缓存与数据库不一致,但有其合理性:
- 性能考虑:回滚时自动清理缓存会增加系统开销。
-
复杂度权衡:在分布式环境中,跟踪和同步所有缓存的回滚操作非常复杂。
-
解决事务回滚后的缓存不一致
对于事务回滚导致的缓存不一致问题,有以下几种解决方案:
- 主动清除缓存:在事务回滚后,手动清除相关缓存。
try {
// 执行更新操作...
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
// 手动清除相关缓存
cacheManager.getCache("products").evict(productId);
}
- 调整缓存配置:可以考虑以下调整:
- 缩短缓存过期时间,减少不一致窗口
- 使用弱一致性缓存策略,接受短暂的不一致
-
对关键数据配置"读时校验"逻辑
-
使用事务同步机制:利用Spring的
TransactionSynchronizationManager在事务完成时执行缓存操作。
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
// 事务回滚后清除缓存
cacheManager.getCache("products").evict(productId);
}
}
});
根据应用的具体需求,选择合适的缓存处理策略,在性能和一致性之间找到平衡点。
小结¶
本节探讨了Jimmer中事务与缓存的协同机制,从事务生命周期中的缓存操作、写入后的缓存一致性保障、多实例环境中的缓存同步、乐观锁与缓存版本控制,以及事务回滚时的缓存处理五个方面,全面解析了二者的交互模式和最佳实践。
这些机制共同构成了Jimmer强大的事务与缓存协同能力,为开发者提供了构建高性能、数据一致的应用的坚实基础。在实际应用中,开发者应根据业务需求和性能要求,选择合适的缓存配置和事务策略,平衡一致性和性能。
在下一节中,我们将深入探讨缓存一致性的挑战和解决方案,包括Jimmer的精确缓存失效机制及其在复杂关联场景下的应用。
6.5.3 缓存一致性:永恒的挑战与Jimmer的解决之道¶
在企业级应用开发中,缓存已成为标配。然而,随之而来的缓存一致性问题也成为了无数开发者的噩梦。在上一节中,我们探索了Jimmer缓存的基础架构及其配置方式,但这仅仅是冰山一角。真正的挑战在于:如何确保缓存中的数据与数据库中的数据保持一致?
这一问题的重要性怎么强调都不为过。想象一下,在电商平台上,用户看到的商品价格与实际结算时的价格不一致;或者在社交应用中,用户已经删除的评论却仍然显示在界面上。这些都是缓存不一致导致的典型问题,不仅影响用户体验,更可能引发业务错误甚至法律纠纷。
6.5.1 缓存一致性问题的本质¶
要理解缓存一致性问题,我们先需要明确其本质。在分布式系统中,完美的缓存一致性几乎是不可能的——这是CAP理论(一致性、可用性、分区容错性)的经典体现。但在实际应用中,我们通常追求的是最终一致性,即缓存数据在一定时间窗口后能够与数据库数据保持一致。
传统的缓存一致性解决方案主要有三种:
- 过期策略:为缓存项设置过期时间,到期后自动失效
- 更新策略:在数据更新时同步更新缓存
- 失效策略:在数据更新时使相关缓存项失效
每种策略都有其优缺点。过期策略实现简单但一致性保障弱;更新策略一致性好但实现复杂;失效策略是一种折中方案,但可能导致缓存频繁失效,影响性能。
更复杂的是,在实际系统中,缓存一致性问题通常呈现为以下三种典型场景:
- 写后读不一致:数据更新后,读操作仍然返回旧数据
- 关联数据不一致:主实体更新后,关联实体的缓存未同步更新
- 并发更新冲突:多个线程同时更新相同数据,导致缓存与数据库不一致
让我们通过一个测试案例来具体展示这些问题:
@Test
@DisplayName("测试缓存透明更新机制")
public void testCacheTransparentUpdate() {
// 1. 第一次查询,将对象加载到缓存
Product product = sqlClient.findById(
ProductFetcher.$.allScalarFields().category(CategoryFetcher.$.name()),
PRODUCT_ID_1
);
assertThat(product).isNotNull();
assertThat(product.name()).isEqualTo("智能手机");
assertThat(product.price()).isEqualTo(new BigDecimal("5999.99"));
// 2. 直接通过SQL更新数据库中的产品价格(绕过ORM层)
jdbcTemplate.update(
"UPDATE t_product SET price = ? WHERE id = ?",
new BigDecimal("4999.99"), PRODUCT_ID_1
);
// 3. 再次查询,验证缓存是否自动更新
Product updatedProduct = sqlClient.findById(
ProductFetcher.$.allScalarFields(),
PRODUCT_ID_1
);
// 验证价格是否已更新
assertThat(updatedProduct.price()).isEqualTo(new BigDecimal("4999.99"));
}
这个测试用例模拟了一个常见场景:通过数据库直接更新了商品价格(可能是由另一个应用或定时任务触发),而我们的应用需要确保用户看到的是最新价格。在传统缓存方案中,这往往需要额外的缓存失效机制,否则用户会继续看到旧价格,直到缓存过期。
6.5.2 Jimmer的透明缓存更新机制¶
Jimmer提供了一种创新的解决方案——透明缓存更新机制。这一机制建立在不可变对象模型的基础上,彻底革新了传统缓存一致性的维护方式。
1. 缓存键的精确定义
Jimmer的缓存系统采用多维度的缓存键设计:
这一设计使得Jimmer能够实现精确到属性级别的缓存控制,避免了"全部缓存或全部失效"的粗粒度操作。
2. 变更检测与缓存同步
Jimmer实现了一套精密的变更检测机制,当实体对象发生变化时,会自动记录变更的确切属性。这些变更信息随后被用于精确控制缓存的更新或失效:
- 仅失效受影响的缓存项
- 保留未受影响的缓存项
- 自动处理级联和关联失效
让我们通过以下测试用例探究Jimmer如何处理复杂关联更新时的缓存一致性:
@Test
@DisplayName("测试复杂关联更新的缓存一致性")
public void testCacheConsistencyWithAssociationChange() {
// 1. 加载产品及其关联的类别
Product product = sqlClient.findById(
ProductFetcher.$.allScalarFields().category(CategoryFetcher.$.allScalarFields()),
PRODUCT_ID_1
);
assertThat(product.category().id()).isEqualTo(CATEGORY_ID_1);
assertThat(product.category().name()).isEqualTo("电子产品");
// 2. 修改产品关联的类别
Product updatedProduct = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID_1);
// 更改商品所属类别
draft.setCategoryId(CATEGORY_ID_2);
});
sqlClient.save(updatedProduct);
// 3. 再次查询产品及其类别
Product productAfterUpdate = sqlClient.findById(
ProductFetcher.$.allScalarFields().category(CategoryFetcher.$.allScalarFields()),
PRODUCT_ID_1
);
// 4. 验证关联的类别已更新
assertThat(productAfterUpdate.category().id()).isEqualTo(CATEGORY_ID_2);
assertThat(productAfterUpdate.category().name()).isEqualTo("家用电器");
// 5. 查询原类别的产品列表
List<Product> category1Products = sqlClient.createQuery(ProductTable.$)
.where(ProductTable.$.categoryId().eq(CATEGORY_ID_1))
.select(ProductTable.$.fetch(ProductFetcher.$.allScalarFields()))
.execute();
// 6. 验证产品已从原类别的产品列表中移除
assertThat(category1Products).hasSize(1);
assertThat(category1Products.get(0).id()).isEqualTo(PRODUCT_ID_2);
}
在这个测试中,我们不仅修改了产品与类别的关联关系,还验证了双向关联的一致性——产品更新了所属类别,同时类别的产品列表也相应更新。在传统ORM中,这通常需要手动维护缓存的多个方面,而Jimmer则自动处理了这一切。
3. 不可变对象的优势
Jimmer的不可变对象模型在缓存一致性方面带来了革命性的优势:
- 天然防止缓存污染:对象不可变意味着缓存中的对象不会被意外修改
- 精确的对象状态:每个对象代表特定时刻的数据快照
- 无需复杂的锁机制:读操作不需要获取锁,提高了并发性能
这些优势使得Jimmer能够在高并发环境下保持缓存一致性,同时最小化性能开销。
6.5.3 并发环境下的缓存一致性¶
在高并发场景下,缓存一致性问题变得更加复杂。多个线程或进程同时对数据进行读写,可能导致数据竞争和不一致状态。传统解决方案通常依赖于悲观锁或乐观锁,但这些方案往往会显著影响性能。
Jimmer通过其独特的架构,提供了一种低成本的并发缓存一致性保障机制。以下测试案例模拟了多线程并发环境下的缓存一致性:
@Test
@DisplayName("测试并发环境下的缓存一致性")
public void testConcurrentCacheConsistency() throws Exception {
// 创建线程池和同步锁
ExecutorService executor = Executors.newFixedThreadPool(5);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(5);
AtomicBoolean hasInconsistency = new AtomicBoolean(false);
try {
// 先查询一次加载缓存
Product initialProduct = sqlClient.findById(
ProductFetcher.$.allScalarFields(),
PRODUCT_ID_1
);
BigDecimal initialPrice = initialProduct.price();
// 启动5个线程同时更新和读取
for (int i = 0; i < 5; i++) {
final int threadNum = i;
executor.submit(() -> {
try {
// 等待统一开始信号
startLatch.await();
if (threadNum % 2 == 0) {
// 偶数线程执行更新
BigDecimal newPrice = initialPrice.add(new BigDecimal(threadNum * 100));
Product updatedDraft = ProductDraft.$.produce(draft -> {
draft.setId(PRODUCT_ID_1);
draft.setPrice(newPrice);
});
sqlClient.save(updatedDraft);
} else {
// 奇数线程执行查询
Product product = sqlClient.findById(
ProductFetcher.$.allScalarFields(),
PRODUCT_ID_1
);
// 验证查询到的价格是否合理
if (product.price().compareTo(initialPrice) < 0) {
hasInconsistency.set(true);
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
endLatch.countDown();
}
});
}
// 发出开始信号
startLatch.countDown();
// 等待所有线程完成
endLatch.await(10, TimeUnit.SECONDS);
// 验证没有出现缓存不一致的情况
assertThat(hasInconsistency.get()).isFalse();
} finally {
executor.shutdown();
}
}
这个测试创建了5个并发线程,其中一些线程更新产品价格,另一些线程读取产品信息。关键验证点是确保没有线程读取到不一致的价格(低于初始价格)。在传统缓存系统中,这种场景容易出现缓存不一致问题,但Jimmer能够优雅地处理这种情况。
Jimmer如何实现这一点?关键在于其底层架构:
- 事务感知的缓存操作:缓存操作与数据库事务紧密集成
- 版本化的缓存项:每个缓存项都有隐含的版本信息
- 原子化的缓存更新:缓存更新作为原子操作执行,避免中间状态
这些机制共同确保了即使在高并发场景下,缓存也能保持与数据库的一致性。
6.5.4 深入理解Jimmer的缓存一致性实现原理¶
要全面把握Jimmer的缓存一致性机制,我们需要深入其内部实现原理。Jimmer的缓存一致性保障基于以下核心机制:
1. 事件驱动的缓存同步
Jimmer实现了一套完整的事件机制,用于在数据变更时触发缓存同步:
这种事件驱动的设计使得缓存操作能够与事务紧密集成,确保事务原子性的同时也保证了缓存一致性。
2. 智能的缓存依赖管理
Jimmer维护了一套精确的缓存依赖关系图,使其能够在实体更新时精确识别所有受影响的缓存项:
- 直接依赖:实体本身的缓存项
- 间接依赖:与实体关联的其他实体的缓存项
- 派生依赖:基于实体计算的衍生数据缓存
这种依赖管理机制确保了在最小化缓存失效范围的同时,不会遗漏任何需要更新的缓存项。
3. 乐观并发控制
对于并发更新场景,Jimmer采用了基于乐观锁的机制,结合不可变对象模型,实现了高效的并发控制:
- 每个实体都有隐含或显式的版本信息
- 更新操作会检查版本一致性
- 版本冲突时会触发乐观锁异常
- 缓存更新会考虑版本信息,确保不会用旧数据覆盖新数据
这种机制在提供并发保护的同时,避免了悲观锁的性能开销。
4. 分布式环境的一致性保障
在分布式系统中,缓存一致性挑战更大。Jimmer通过以下机制应对这一挑战:
- 分布式事件传播:变更事件可通过消息中间件传播到所有节点
- 缓存协议支持:兼容常见的分布式缓存协议(如Redis的发布订阅)
- 时间戳机制:使用全局时间戳解决跨节点数据新旧判断问题
这些机制使得Jimmer能够在分布式环境中提供强大的缓存一致性保障。
6.5.5 缓存一致性的最佳实践¶
基于对Jimmer缓存一致性机制的深入理解,我们可以总结出以下最佳实践,帮助开发者在实际项目中充分利用Jimmer的优势:
1. 合理规划缓存粒度
虽然Jimmer提供了强大的缓存一致性机制,但合理的缓存粒度设计仍然非常重要:
- 对于频繁变化的数据,考虑使用较小的缓存粒度
- 对于稳定的数据,可以使用较大的缓存粒度
- 尽量避免将不相关的数据捆绑在同一缓存项中
2. 利用缓存策略注解
Jimmer提供了丰富的注解,用于控制缓存行为:
@Entity
@Cache(
properties = "*", // 缓存所有属性
relations = @CacheRelation(value = "products") // 缓存关联的产品列表
)
public interface Category {
// ...
}
@Entity
@Cache(
properties = {"name", "price", "stock"}, // 只缓存特定属性
relations = @CacheRelation(value = "category") // 缓存关联的类别
)
public interface Product {
// ...
}
通过这些注解,可以精确控制哪些数据应该被缓存,以及如何处理关联缓存,从而优化缓存效率和一致性。
3. 处理外部系统导致的数据变更
对于可能被外部系统直接修改的数据,可以采取以下策略:
- 设置合理的缓存过期时间,确保最终一致性
- 利用Jimmer的缓存刷新机制,主动检测数据变更
- 实现缓存同步监听器,响应外部变更事件
@Bean
public CacheSyncAspect cacheSyncAspect(JSqlClient sqlClient) {
return CacheSyncAspect.builder()
.sqlClient(sqlClient)
// 配置外部系统变更监听
.externalTableSynchronizer(tables -> {
tables.add("t_product");
tables.add("t_category");
})
.build();
}
4. 监控缓存一致性
为了确保缓存一致性在生产环境中正常工作,应该实施有效的监控机制:
- 记录缓存命中率和失效事件
- 设置缓存一致性检查点,定期验证数据一致性
- 实现异常监控,及时发现并修复缓存问题
Jimmer提供了完整的监控支持,使开发者能够全面了解缓存运行状况。
6.5.6 实际应用案例:电商平台商品系统¶
为了将前面讨论的理论和技术付诸实践,让我们来看一个实际应用案例——电商平台的商品系统。这个系统面临典型的缓存一致性挑战:
- 商品数据被频繁查询,需要高效缓存
- 商品价格和库存经常变化,需要及时反映在前端
- 商品与多个实体(类别、标签、评价等)有复杂关联
- 系统中存在多个服务和应用并发操作商品数据
使用Jimmer实现这一系统的核心部分如下:
实体定义
@Entity
@Cache(
properties = "*",
relations = {
@CacheRelation("products")
}
)
public interface Category {
@Id
String id();
String name();
@OneToMany(mappedBy = "category")
List<Product> products();
// ...其他属性
}
@Entity
@Cache(
properties = {"id", "name", "price", "stock", "active"},
relations = {
@CacheRelation("category")
}
)
public interface Product {
@Id
String id();
String name();
BigDecimal price();
int stock();
boolean active();
@ManyToOne
@Nullable
Category category();
// ...其他属性
}
服务层实现
@Service
@Transactional
public class ProductService {
private final JSqlClient sqlClient;
@Autowired
public ProductService(JSqlClient sqlClient) {
this.sqlClient = sqlClient;
}
// 更新商品价格
public Product updatePrice(String productId, BigDecimal newPrice) {
Product draft = ProductDraft.$.produce(p -> {
p.setId(productId);
p.setPrice(newPrice);
});
return sqlClient.save(draft).getModifiedEntity();
}
// 更新商品库存(可能并发)
public int decreaseStock(String productId, int quantity) {
return sqlClient.createUpdate(ProductTable.class)
.set(ProductTable::stock, table -> table.stock() - quantity)
.where(ProductTable::id, productId)
.where(ProductTable::stock, quantity)
.execute();
}
// 查询商品详情(高频读取)
public Product getProductDetail(String productId) {
return sqlClient.findById(
ProductFetcher.$.allScalarFields().category(
CategoryFetcher.$.name()
),
productId
);
}
// 变更商品类别
public Product changeCategory(String productId, String newCategoryId) {
Product draft = ProductDraft.$.produce(p -> {
p.setId(productId);
p.setCategoryId(newCategoryId);
});
return sqlClient.save(draft).getModifiedEntity();
}
}
在这个实现中,我们可以看到Jimmer如何在实际场景中解决缓存一致性问题:
- 精确的缓存控制:通过
@Cache注解精确控制缓存范围 - 简洁的更新操作:利用不可变对象模型实现简洁的部分更新
- 自动的关联处理:更改商品类别时,相关的类别-商品关联自动更新
- 并发安全保障:库存减少操作在并发环境下仍然安全可靠
通过这一实现,电商平台能够在保证数据一致性的同时,实现高效的缓存利用,提升系统整体性能和用户体验。
6.5.7 总结与展望¶
缓存一致性是分布式系统中的永恒挑战,也是评判ORM框架成熟度的重要标准。通过本节的学习,我们深入探讨了缓存一致性问题的本质,以及Jimmer提供的创新解决方案。
Jimmer的缓存一致性机制建立在其不可变对象模型和精确的变更跟踪基础上,提供了以下核心优势:
- 精确的缓存控制:能够精确到属性级别的缓存管理
- 关联数据一致性:自动处理复杂关联数据的缓存更新
- 并发环境支持:在高并发环境中保持缓存一致性
- 分布式系统支持:解决分布式环境下的缓存同步挑战
这些特性使得Jimmer成为处理复杂业务场景的理想选择,特别是对于那些同时要求高性能和数据一致性的应用。
在下一节中,我们将继续深入探索Jimmer的高级特性——缓存事务,看看Jimmer如何将事务管理与缓存机制无缝集成,为开发者提供更强大、更易用的数据访问解决方案。
6.6 小结与展望¶
在本章中,我们深入探讨了Jimmer框架中事务与缓存的协同机制,揭示了这一设计如何优雅地解决数据一致性与系统性能之间的矛盾。现在,让我们对这些内容进行梳理,并展望未来的应用方向。
6.6.1 事务与缓存设计的核心原则¶
通过本章的学习和实践,我们可以总结出Jimmer事务与缓存设计的几个核心原则,这些原则不仅适用于Jimmer框架,也为其他系统的设计提供了宝贵的参考:
不变性优先原则
Jimmer框架基于不可变对象模型设计,这一基础选择从根本上简化了缓存一致性问题。不可变对象创建后永不变化,修改操作实际上是创建新对象,这使得缓存策略更加清晰。
// 示例:不变性如何简化缓存管理
Product originalProduct = sqlClient.findById(Product.class, "p-001");
// originalProduct被缓存,其数据永远保持不变
// 修改操作创建新对象,而不是改变原对象
Product updatedProduct = ProductDraft.$.produce(originalProduct, draft -> {
draft.setPrice(new BigDecimal("1099.99"));
});
sqlClient.save(updatedProduct);
// 缓存系统可以安全地保留originalProduct,同时增加updatedProduct的缓存
事务感知原则
缓存操作应该感知事务状态,确保缓存一致性与事务ACID特性协同工作。在我们的testTransactionSynchronizationWithCache测试用例中,我们看到了如何通过事务同步器实现这一目标:
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int statusCode) {
if (statusCode == TransactionSynchronization.STATUS_ROLLED_BACK) {
// 事务回滚时清除相关缓存
cacheManager.getCache(cacheName).clear();
}
}
});
细粒度控制原则
缓存的控制应当精确到实体甚至属性级别,避免全局缓存失效带来的性能损失。在高并发环境下,这种精细化控制尤为重要:
// 示例:只针对特定实体ID的缓存进行操作
var productCache = sqlClient.getCaches().getObjectCache(Product.class);
if (productCache != null) {
productCache.delete(productId); // 只清除特定商品的缓存
}
分层缓存原则
通过分层缓存设计,可以在一致性和性能之间取得最佳平衡。Jimmer的缓存架构包括:
- 事务级缓存:确保事务内一致性
- 应用级缓存:提高单实例性能
- 分布式缓存:支持集群环境
6.6.2 Jimmer的创新与价值¶
通过详细分析Jimmer框架的事务与缓存机制,我们可以看到Jimmer在这一领域的几个关键创新:
事务与缓存的一体化设计
与传统ORM框架将事务管理与缓存视为独立组件不同,Jimmer将二者视为有机整体进行设计。这种一体化设计在我们的testCacheAndTransactionIntegration测试用例中得到了充分体现:
即使在事务执行后,由于缓存与事务的协同工作,后续查询的性能有显著提升。
精确缓存失效机制
Jimmer能够精确追踪数据变更,只失效必要的缓存项,而不是盲目地清除整个缓存。这种精确失效机制在高并发环境下尤为重要,正如我们在testCombinedCacheStrategies测试中所见:
// 只清除特定商品的缓存
sqlClient.getCaches().getObjectCache(Product.class).delete(phone.id());
// 关联对象的缓存更新
Category refreshedElectronics = sqlClient.findById(
CategoryFetcher.$.allScalarFields().products(ProductFetcher.$.allScalarFields()),
electronicCategory.id()
);
并发事务处理
Jimmer在处理并发事务时表现出色,保证了数据一致性的同时维持了缓存性能。我们的测试显示,即使在多个事务并发修改同一实体的情况下,Jimmer也能正确处理最终状态:
6.6.3 未来发展趋势¶
随着企业应用系统复杂度的提升和性能需求的增长,事务与缓存协同机制将继续发展,以下是几个值得关注的发展方向:
缓存预测与自适应优化
未来的缓存系统可能会引入机器学习能力,预测数据访问模式并自动调整缓存策略:
// 未来可能的API
cacheManager.setAdaptiveStrategy(
AdaptiveStrategy.builder()
.withAccessPatternAnalysis(true)
.withAutomaticWarmup(true)
.withResourceAdaptation(true)
.build()
);
这种智能缓存系统可以分析应用的访问模式,预测哪些数据将被频繁访问,主动预热这些数据,并在系统负载变化时自动调整缓存策略。
跨微服务事务与缓存协调
在微服务架构中,跨服务的事务与缓存一致性是一个复杂问题。未来可能出现更高级的协调机制:
边缘计算环境下的缓存策略
随着边缘计算的兴起,缓存策略需要考虑网络拓扑和延迟:
在这种环境下,缓存策略需要综合考虑数据位置、访问频率和网络拓扑,自动决定最优的缓存分布。
实时数据处理与缓存
对于需要实时数据处理的应用,如金融交易和IoT系统,传统缓存策略可能不再适用。未来的缓存系统可能结合流处理能力: