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上下文中重要性的文章。相信我,这很重要。

本文基于我在2021年Spring One大会上的演讲的一部分。

为什么Spring Data不缓存?

Spring Data JDBC的一个重要设计决定是“不”包含缓存。这样做的原因,与许多其他决策一样,来自我们使用JPA的经验。让我们看看JPA及其处理缓存的方式。

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

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

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

  2. 缓存。

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

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

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

  • 他们在内存中编辑实体,并希望将其与数据库中的状态进行比较,结果再次惊讶地发现他们不断获得已经更改的版本。

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

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

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

毫无疑问,在某些情况下,缓存是正确的做法。每当你有很多读取但变化不快的数据时,缓存都是一个合理的选择。

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

这简直太简单了,难以置信。

示例

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

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

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

	public Long getId(){
		return id;
	}
}

请注意仓库上的缓存相关注解。

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作为键。我们通过使用SpEL表达式来实现这一点。id通常只有在保存实体后才可用,因此我们使用beforeInvocation = false。使用SpEL强制我们使Minion公开并添加一个公共的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 社区所有即将举行的活动。

查看所有