跳到主要内容

计算缓存

所谓计算缓存,指把当前对象id映射为用户定义的复杂计算属性的计算值。

计算属性回顾

复杂计算属性一文中,我们详细地讲解了复杂计算属性。

警告

本文聚焦于计算缓存,并不重复介绍复杂计算属性,请读者先了解复杂计算属性再阅读此文

在本文中,我们将为复杂计算属性中定义的计算属性BookStore.avgPrice添加缓存支持。

信息

为简化文档,本文只讨论BookStore.avgPrice,不讨论另外一个关联型计算属性BookStore.newestBooks,读者可阅读和运行如下官方例子

  • jimmer-examples/java/jimmer-sql
  • jimmer-examples/java/jimmer-sql-graphql
  • jimmer-examples/kotlin/jimmer-sql-kt
  • jimmer-examples/kotlin/jimmer-sql-graphql-kt

启用计算缓存

@Bean
public CacheFactory cacheFactory(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper
) {
return new CacheFactory() {

@Override
public Cache<?, ?> createObjectCache(@NotNull ImmutableType type) {
...省略代码...
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
...省略代码...
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
...省略代码...
}

@Override
public Cache<?, ?> createResolverCache(ImmutableProp prop) {
return createPropCache(
prop,
Duration.ofHours(1),
Duration.ofHours(24)
);
}

private <K, V> Cache<K, V> createPropCache(
ImmutableProp prop,
Duration caffeineDuration,
Duration redisDuration
) {
return new ChainCacheBuilder<>()
.add(
CaffeineValueBinder
.forProp(prop)
.maximumSize(512)
.duration(caffeineDuration)
.build()
)
.add(
RedisValueBinder
.forProp(prop)
.redis(connectionFactory)
.objectMapper(objectMapper)
.duration(redisDuration)
.build()
)
.build();
}
};
}

使用

BookStoreTable table = Tables.BOOK_STORE_TABLE;
List<BookStore> stores = sqlClient
.createQuery(table)
.select(
table.fetch(
Fetchers.BOOK_STORE_FETCHER
.allScalarFields()
.avgPrice()
)
)
.execute();
System.out.println(stores);
  • 第一步:查询聚合根

    首先查询聚合根对象,执行如下SQL

    select
    tb_1_.ID,
    tb_1_.NAME,
    tb_1_.WEBSITE
    from BOOK_STORE tb_1_

    这里实现了代码中的查询,得到了一些BookStore对象。这种被用户直接查询而得的对象叫做聚合根对象

    警告

    Jimmer不会对用户查询返回的聚合对象进行缓存,因为这种查询结果的一致性无法保证。 即便需要以牺牲一致性为代价对其缓存,也是用户的业务问题,不是应该由框架抽象并统一的行为。

  • 第二步:通过计算缓存把当前对象id转化为计算值

    上面的代码会得到一系列聚合根对象,如果数据库采用官方例子的数据,会得到两个聚合根对象。

    代码中的对象抓取器包含了计算属性BookStore.avgPrice

    这2条BOOK_STORE的主键ID为1和2。

    Jimmer先从Redis查找数据,被查找的键为BookStore.avgPrice-1BookStore.avgPrice-2

    假设无法在Redis中找到这些键所对应的数据

    127.0.0.1:6379> keys BookStore.avgPrice-*
    (empty array)

    所以,执行如下SQL,完成计算属性的计算

    select
    tb_1_.ID,
    avg(tb_2_.PRICE)
    from BOOK_STORE tb_1_
    left join BOOK tb_2_
    on tb_1_.ID = tb_2_.STORE_ID
    where
    tb_1_.ID in (
    ? /* 1 */, ? /* 2 */
    )
    group by
    tb_1_.ID

    Jimmer会把查询结果放入Redis,因此,我们可以从redis中查看这些数据

    127.0.0.1:6379> keys BookStore.avgPrice-*
    1) "BookStore.avgPrice-2"
    2) "BookStore.avgPrice-1"
    127.0.0.1:6379> get BookStore.avgPrice-1
    "58.5"
    127.0.0.1:6379> get BookStore.avgPrice-2
    "80.333333"
    127.0.0.1:6379>

    这样,两个BookStore对象可以通过其计算属性BookStore.avgPrice得到各自书籍的平均价格。

    毫无疑问,在Redis中的数据因超时而被清除之前,再次执行上述Java/Kotlin代码,将直接从Redis中返回计算数据,第二条SQL不会被生成。

最终,Jimmer把3个步骤的结果拼接在一起,作为最终返回给用户的数据

[
{
"id":2,
"name":"MANNING",
"website":null,
"avgPrice":58.5
},
{
"id":1,
"name":"O'REILLY",
"website":null,
"avgPrice":80.333333
}
]

一致性

响应触发器

信息

和对象缓存、关联缓存那种全自动的缓存一致性不同,计算缓存的一致性维护需要用户辅助。

这是因为计算属性引入了ORM框架无法理解的用户自定义计算规则

BookStore.avgPrice这个计算属性而言,以下两种情况都会导致计算缓存的失效

  • 修改BOOK记录的STORE_ID外键字段,新旧值对应的两个书店的avgPrice缓存数据都需要被删除

  • 修改BOOK记录的PRICE字段,其所属书店的avgPrice缓存数据需要被删除

复杂计算属性一文中,为支持计算属性BookStore.avgPrice定义了一个类BookStoreAvgPriceResolver,其代码如下。

BookStoreAvgPriceResolver.java
package com.example.business.resolver;

import org.babyfish.jimmer.sql.*;
import org.babyfish.jimmer.sql.TransientResolver;
import org.springframework.stereotype.Component;

@Component
public class BookStoreAvgPriceResolver implements TransientResolver<Long, BigDecimal> {

@Override
public Map<Long, BigDecimal> resolve(Collection<Long> ids) {
...省略代码...
}

@Override
public BigDecimal getDefaultValue() {
...省略代码...
}
}

我们需要修改此类,覆盖如下两个方法

BookStoreAvgPriceResolver.java
package com.example.business.resolver;

import org.babyfish.jimmer.sql.*;
import org.babyfish.jimmer.sql.TransientResolver;
import org.springframework.stereotype.Component;

@Component
public class BookStoreAvgPriceResolver implements TransientResolver<Long, BigDecimal> {

// 构造注入sqlClient
private final JSqlClient sqlClient;

...省略其他代码...

@Override
Collection<?> getAffectedSourceIds(@NotNull EntityEvent<?> e) {
// TODO
}

@Override
Collection<?> getAffectedSourceIds(@NotNull AssociationEvent e) {
// TODO
}
}

这两个方法是TransientResolver内置的触发器响应方法,在数据库变化时被自动执行,负责在数据库变化时自动清理计算缓存。

接下来,我们实现这两个方法。

当外键BOOK.STORE_ID被修改时

用户可以通过修改BOOK表的外键STORE_ID来改变BOOK_STOREBOOK之间的关联,这必然会影响某些书店的BookStore.avgPrice

提示

如果关注多对一关联Book.store的变化,那么修改前的旧值和修改后的新值就是两个父对象,要分别考虑它们是否为null,代码会稍微繁琐一点。

幸运的是,本例所基于的实体模型中,存在逆向的一对多关联BookStore.books。如果监听一对多关联BookStore.books的变化,我们只需要考虑当前BookStore对象的id即可,代码会得到简化。

实现getAffectedSourceIds(AssociationEvent),如下

@Override
public Collection<?> getAffectedSourceIds(AssociationEvent e) {
if (sqlClient.getCaches().isAffectedBy(e) &&
e.getImmutableProp() == BookStoreProps.BOOKS.unwrap()
) {
return Collections.singletonList(e.getSourceId());
}
return null;
}
  • ❶ 如果触发器的类型被设置为BOTH,任何修改导致的触发器事件通知都会被执行两次。

    备注

    第1次:e.connection非null,表示这是Transaction触发器发出的通知。

    第2次:e.connection为null,表示这是BinLog触发器发出的通知。

    然而,计算缓存的一致性维护工作只需要做一次,无需做两次。

    sqlClient.caches.isAffectedBy(e)可以解决这个问题,即使触发器的类型被设置为BOTH,也可保证这里的代码只会执行一次。

    警告

    无论Jimmer的触发器的类型是否被设置为BOTH,都建议将此检查作为一个纪律性行为。

  • ❷ 如果一对多关联BookStore.books被修改

  • ❹ 那么,该关联修改事件的sourceId (即,BookStore的id) 所对应的计算属性BookStore.avgPrice的缓存需要被清理。

现在,让我们来验证修改Book.store的效果

  • 假如启用了BinLog触发器,用任何手段修改数据库都可以导致Jimmer缓存一致性的介入。比如直接在SQL IDE中执行如下SQL

    update BOOK 
    /* Old value: 1, New value: 2 */
    set STORE_ID = 2
    where ID = 7;
  • 假如只启用了Transaction触发器,则必须用Jimmer的API修改数据库

    BookTable table = Tables.BOOK_TABLE;
    sqlClient
    .createUpdate(table)
    // Old value: 1L, New value: 2L
    .set(table.store().id, 2L)
    .where(table.id().eq(7L))
    .execute();

无论通过上述何种方式修改数据,你都会在看到如下日志输出

Delete data from redis: [Book-7]
Delete data from redis: [Book.store-7]
Delete data from redis: [BookStore.avgPrice-1] ❶
Delete data from redis: [BookStore.books-1]
Delete data from redis: [BookStore.avgPrice-2] ❷
Delete data from redis: [BookStore.books-2]
  • ❶ 外键修改前的旧值1所引用的父对象的计算缓存BookStore.avgPrice-1被删除

  • ❷ 外键修改后的新值2所引用的父对象的计算缓存BookStore.avgPrice-2被删除

当BOOK.PRICE被修改时

用户还可以修改书籍的价格,这必然会影响其所属书店的BookStore.avgPrice

实现getAffectedSourceIds(EntityEvent<?>),如下

@Override
public Collection<?> getAffectedSourceIds(EntityEvent<?> e) {
if (sqlClient().getCaches().isAffectedBy(e) &&
!e.isEvict() &&
e.getImmutableType().getJavaClass() == Book.class) {

Ref<BookStore> storeRef = e.getUnchangedRef(BookProps.STORE);
if (storeRef != null && storeRef.getValue() != null && e.isChanged(BookProps.PRICE)) {
return Collections.singletonList(storeRef.getValue().id());
}
}
return null;
}
  • ❶ 如果触发器的类型被设置为BOTH,任何修改导致的触发器事件通知都会被执行两次。

    备注

    第1次:e.connection非null,表示这是Transaction触发器发出的通知。

    第2次:e.connection为null,表示这是BinLog触发器发出的通知。

    然而,计算缓存的一致性维护工作只需要做一次,无需做两次。

    sqlClient.caches.isAffectedBy(e)可以解决这个问题,即使触发器的类型被设置为BOTH,也可保证这里的代码只会执行一次。

    警告

    无论Jimmer的触发器的类型是否被设置为BOTH,都建议将此检查作为一个纪律性行为。

  • ❷ 两种原因都导致Jimmer的事件回调,无论EntityEvent还是AssociationEvent

    1. 明确得知道数据库被修改

      此时,isEvict()方法返回false。用户可以访问EntityEvent/AssociationEvent的任何属性。

    2. 在具备连锁效应的自动清理缓存的过程中,某个对象/关联的缓存需要被清理

      此时,isEvict()方法返回true。除了EntityEvent.id/AssociationEvent.sourceId外,事件对象不支持任何属性,比如EntityEvent.newEntityAssociationEvent.attachedTargetId

    这里,我们需要明确地判断用户是否修改了BOOK表的PRICE字段,所以,我们必须判断isEvict()为false。

    注意

    是否判断e.isEvict(),必须具体情况具体分析。

  • ❸ 确定当前事件被触发是因为Book类型的对象被修改了。

  • ❹ ❺ e.getUnchangedRef(BookProps.STORE)/e.getUnchangedRef(Book::store)表示, 如果基于外键的Book.store没有被修改,返回其一个Ref包装对象,其内容是未被修改的关联对象 (只有id属性) 或null。

    信息
    • 如果getUnchangedRef返回的包装对象Ref本身为null,表示这个属性被修改了,并非Unchanged属性。

    • 如果getUnchangedRef返回的包装对象Ref并非null,但其内部的value为null,表示这个属性未被修改,且其值一直保持为null。

    最终,我们期望外键字段BOOK.STORE_ID没有被修改,并一直不为null。

    提示

    这里,无需考虑外键字段被修改的情况,因为前面我们已经讨论过的另外一个方法getAffectedSourceIds(AssociationEvent)会妥善处理这种情况。

  • ❻ 如果以上条件都满足,那么price被修改的Book所属的BookStore父对象的计算属性BookStore.avgPrice的缓存需要被清理。

现在,让我们来验证修改Book.price的效果

  • 假如启用了BinLog触发器,用任何手段修改数据库都可以导致Jimmer缓存一致性的介入。比如直接在SQL IDE中执行如下SQL

    update BOOK 
    set PRICE = PRICE + 1
    where ID = 7;
  • 假如只启用了Transaction触发器,则必须用Jimmer的API修改数据库

    BookTable table = Tables.BOOK_TABLE;
    sqlClient
    .createUpdate(table)
    .set(table.price(), table.price().plus(BigDecimal.ONE))
    .where(table.id().eq(7L))
    .execute();

无论通过上述何种方式修改数据,你都会在看到如下日志输出

Delete data from redis: [BookStore.avgPrice-1] ❶
Delete data from redis: [Book-7]
  • ❶ 外键所引用的父对象的计算缓存BookStore.avgPrice-1被删除