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 社群的所有近期活動。

檢視全部