Spring Data JDBC - 如何实现缓存?

工程 | Jens Schauder | 2021年10月18日 | ...

本文是关于如何在使用 Spring Data JDBC 时应对各种挑战系列文章的第三篇。

本系列包括:

  1. Spring Data JDBC - 如何使用自定义 ID 生成。

  2. Spring Data JDBC - 如何实现双向关系?

  3. Spring Data JDBC - 如何实现缓存?(本文)

  4. Spring Data JDBC - 如何对聚合根进行部分更新?

  5. Spring Data JDBC - 如何为我的领域模型生成 Schema?

如果您是 Spring Data JDBC 的新手,应该先阅读其介绍这篇解释聚合在 Spring Data JDBC 上下文中的重要性的文章。相信我,这很重要。

本文基于我在 Spring One 2021 上发表的演讲的一部分。

为什么 Spring Data 不提供缓存?

Spring Data JDBC 的一个重要设计决策是**不**包含缓存。其原因与许多其他决策一样,源于我们使用 JPA 的经验。让我们看看 JPA 以及它如何处理缓存。

JPA 做出了一个相当强的承诺:无论何时在会话中加载逻辑上相同的实体,您总是会获得完全相同的实例。这听起来当然很方便。当您通过 ID 访问实体时,这通常可以节省一次数据库往返。但这样做的原因是,它实际上是 JPA 正常工作所必需的。JPA 会跟踪您实体的更改,以便最终将这些更改刷新到数据库。如果一个逻辑实体由多个具有潜在不同和相互矛盾状态的 Java 实例表示,这将无法工作。

为了实现这个承诺,JPA 使用了“一级缓存”,从而混合了两个截然不同的任务:

  1. 在内存和数据库之间传输对象。

  2. 缓存。

这反过来又会引起问题,特别是当开发人员忘记缓存或一开始就没有了解它时。

  • 他们使用 SQL 更新实体,但无法使用 JPA 加载更新后的状态,因为 JPA 总是返回已加载的实体。

  • 他们在内存中编辑实体,却惊讶地发现它被保存到数据库中,尽管他们从未调用过执行此操作的方法。

  • 他们在内存中编辑实体,并想将其与数据库中的状态进行比较,再次惊讶地发现他们一直在获取已更改的版本。

  • 他们运行大型批处理任务,却惊讶地发现他们的实体没有被垃圾回收,导致巨大的内存占用、糟糕的性能以及可能的内存不足异常。

Spring Data JDBC 中的关注点分离使得事情更加透明。当您在相应的 Repository 上调用 save() 时,实体会被保存到数据库。当您调用一个从 Repository 返回一个或多个实体的方法时,它会从数据库加载。

如果我仍然想要缓存怎么办?

毫无疑问,在某些情况下缓存是正确的选择。无论何时,如果您有大量读取但变化不快的数据,缓存都是一个合理的选项。

由于缓存不是 Spring Data JDBC 的一部分,并且 Spring Data JDBC 的 Repository 只是 Spring Bean,您可以将其与任何您喜欢的缓存解决方案结合使用。显而易见的选择当然是Spring 的缓存抽象,您可以在其背后放置任何缓存解决方案。

这简直简单得令人难以置信。

示例

为了演示目的,我再次使用了备受喜爱的 Minion 实体及其匹配的 Repository。

public class Minion {
	@Id
	Long id;
	String name;

	Minion(String name) {
		this.name = name;
	}

	public Long getId(){
		return id;
	}
}

注意 Repository 上与缓存相关的注解。

interface MinionRepository extends CrudRepository<Minion, Long> {

	@Override
	@CacheEvict(cacheNames = "minions", beforeInvocation = false, key = "#result.id")
	<S extends Minion> S save(S s);

	@Override
	@Cacheable("minions")
	Optional<Minion> findById(Long aLong);
}

@CacheEvict 注解不像人们期望的那么简单,因为 save 方法接受一个实体,但我们需要它的 id 作为 key。我们通过使用 SpEL 表达式来实现这一点。一般来说,id 只有在保存实体后才可用,因此我们使用了 beforeInvocation = false。而使用 SpEL 迫使我们将 Minion 设置为 public 并添加一个 public 的 getId() 方法。

注意,我们需要通过向 Boot 应用程序添加 @EnableCaching 来启用缓存。

@EnableCaching
@SpringBootApplication
class CachingApplication {

	public static void main(String[] args) {
		SpringApplication.run(CachingApplication.class, args);
	}

}

最后,我们需要一个测试来验证重复访问数据库时,只有在保存后才会进行一次 SELECT 操作。

@SpringBootTest
class CachingApplicationTests {

	private Long bobsId;
	@Autowired MinionRepository minions;

	@BeforeEach
	void setup() {

		Minion bob = minions.save(new Minion("Bob"));
		bobsId = bob.id;
	}

	@Test
	void saveloadMultipleTimes() {

		Optional<Minion> bob = null;
		for (int i = 0; i < 10; i++) {
			bob = minions.findById(bobsId);
		}

		minions.save(bob.get());

		for (int i = 0; i < 10; i++) {
			bob = minions.findById(bobsId);
		}

	}

}

为了观察运行测试时发生了什么,我们可以在 application.properties 中启用 SQL 语句的日志记录。

logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG

这些是在日志中出现的 SQL 语句:

INSERT INTO "MINION" ("NAME") VALUES (?)]
SELECT "MINION"."ID" AS "ID", "MINION"."NAME" AS "NAME" FROM "MINION" WHERE "MINION"."ID" = ?]
UPDATE "MINION" SET "NAME" = ? WHERE "MINION"."ID" = ?]
SELECT "MINION"."ID" AS "ID", "MINION"."NAME" AS "NAME" FROM "MINION" WHERE "MINION"."ID" = ?]

因此,缓存按预期工作。对 findById 进行缓存避免了重复的 SELECT 查询,而 save 操作会触发从缓存中逐出实体。

在本示例中,我们使用了简单的缓存,它只是一个 ConcurrentMap。对于生产环境,您可能需要一个更完善的缓存实现,可以配置逐出策略等。但它与 Spring Data JDBC 的用法保持不变。

结论

Spring Data JDBC 专注于其本职工作:持久化和加载聚合。缓存是与此正交的功能,可以使用众所周知的 Spring Cache 抽象来添加。

完整的示例代码可在 Spring Data 示例仓库中找到

后续还会有更多类似的文章。如果您希望我涵盖特定主题,请告诉我。

订阅 Spring 电子报

订阅 Spring 电子报,保持联系

订阅

保持领先

VMware 提供培训和认证,助力您的进步。

了解更多

获取支持

Tanzu Spring 通过一项简单的订阅,提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

近期活动

查看 Spring 社区的所有近期活动。

查看全部