opengoofy/crane4j

使用 @ContainerCache 添加缓存后,即使缓存全部命中依然会调用查询方法

Closed this issue · 7 comments

测试了一下,貌似foreach 执行 Crane4jTemplate.execute(obj) , 每次都会重新查库,没有使用缓存

换成Crane4jTemplate.execute(list),就能正常使用缓存,同一个id只查了一次。

测试了一下,貌似foreach 执行 Crane4jTemplate.execute(obj) , 每次都会重新查库,没有使用缓存

换成Crane4jTemplate.execute(list),就能正常使用缓存,同一个id只查了一次。

首先,参见文档中的缓存部分,如果你指的“缓存”是通过在数据源上添加 @ContainerCache 注解、或者直接在配置文件里面针对数据源配置的缓存,那么这个缓存是全局的,在你描述的两种场景中,只要缓存没有过期,那么它们应该都是生效的。

不过,如果你纠结的是为什么单次批量执行和循环单次执行查库次数不一样,那么批量查询的过程中,实际上并没有任何缓存,只查一次是因为 Crane4j 的执行器自动合并了数据源相同的查询。

如果你感觉有点抽象,那么你可以把 Crane4j 的填充行为理解成这样一个方法:

public void fill(Collection<targets> targets) {
    // 将待填充的对象根据key分组
    Map<Integer, List<Foo>> keyWithTarget = targets.stream()
        .collect(Collectors.groupBy(Foo::getId));
    Set<Integer> keys = keyWithTarget.keySet();
    
    // 根据key查询数据源
    Map<Integer, Data> dataWithKey = datasource.list(keys).stream()
        .collect(Collectors.toMap(Data::getId, Function::identity));
    
    // 根据key获取待填充对象对应数据,然后进行填充
    keyWithTarget.forEach((key, foos) -> {
        Data data = dataWithKey.get(key);
        if (Objects.nonNull(data)) {
            // 执行填充
            foos.forEach(foo -> foo.setName(data.getName));
        }
    ]})
}

你每次调用 Crane4jTemplate.execute 方法,都等同于调用一次上面的这个方法,所以你批量执行就会只查一次库,而循环执行则会每次都查一次库。

public class TaskExecutionDataResp{
@Assemble(container = "task", props = @Mapping(src = "name", ref = "taskName"))
    private Long taskId;
private String taskName;
    }
    
    // TasksServiceImpl

    @GuavaContainerCache
    @ContainerMethod(
            namespace = "task",
            resultType = TasksDO.class, resultKey = "id"
    )
    public TasksDO getById(Long id) {
        return baseMapper.selectById(id);
    }

  // query method
@Override
    public PageResp<TaskExecutionDataResp> page(TaskExecutionDataQuery query, PageQuery pageQuery) {
        QueryWrapper<TaskExecutionDataDO> queryWrapper = this.buildQueryWrapper(query);
        IPage<TaskExecutionDataDO> page = ((BaseMapper) this.baseMapper).selectPage(pageQuery.toPage(), queryWrapper);
        PageResp<TaskExecutionDataResp> pageResp = PageResp.build(page, this.listClass);
        
        List<TaskExecutionDataResp> list = pageResp.getList();
        Crane4jTemplate bean = SpringUtil.getBean(Crane4jTemplate.class);
        // 触发了10次task getById的查询,正常应该只会触发一次,后续从缓存获取了吧?
        list.forEach(taskExecutionDataResp -> bean.execute(Collections.singleton(taskExecutionDataResp)));
        return pageResp;
    }

我指的就是数据源加缓存的情况,那看起来应该是缓存没生效?还需要其他什么配置么?

cn.crane4j.core.cache.CacheableContainer#get

追了下源码,没太看懂这是什么情况😂,大佬解答一下?

CleanShot 2024-06-13 at 10 55 16@2x
CleanShot 2024-06-13 at 10 55 26@2x

问题

你是对的,我用下面这个测试用例验证了一下,现在缓存确实存在问题:

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = { DefaultCrane4jSpringConfiguration.class, GitHub304Test.ServiceImpl.class })
public class GitHub304Test {

    @Autowired
    private Crane4jGlobalConfiguration configuration;
    @Autowired
    private ServiceImpl serviceImpl;

    @Test
    public void test() {
        Container<?> container = configuration.getContainer("test");
        Assert.assertTrue(container instanceof CacheableContainer);
        CacheableContainer<?> cacheableContainer = (CacheableContainer<?>) container;
        CacheDefinition cacheDefinition = cacheableContainer.getCacheDefinition();
        Assert.assertEquals(CacheManager.DEFAULT_GUAVA_CACHE_MANAGER_NAME, cacheDefinition.getCacheManager());

        Map<Integer, Foo> data1 = (Map<Integer, Foo>) ((Container<Integer>)container).get(Collections.singletonList(1));
        Assert.assertEquals(1, data1.size());
        Foo foo1 = data1.get(1);
        Assert.assertNotNull(foo1);
        Assert.assertEquals(1, serviceImpl.getCounter().get());

        Map<Integer, Foo> data2 = (Map<Integer, Foo>) ((Container<Integer>)container).get(Collections.singletonList(1));
        Assert.assertEquals(1, data2.size());
        Foo foo2 = data2.get(1);
        Assert.assertNotNull(foo2);

        Assert.assertSame(foo1, foo2); // 缓存确实生效了
        Assert.assertEquals(1, serviceImpl.getCounter().get()); // 断言失败,实际的 counter 为 2
    }

    @Component
    public static class ServiceImpl {
        @Getter
        private AtomicInteger counter = new AtomicInteger(0);
        @GuavaContainerCache
        @ContainerMethod(namespace = "test", resultType = Foo.class)
        public List<Foo> getByIds(Collection<Integer> ids) {
            counter.incrementAndGet();
            return ids.stream()
                .map(Foo::new)
                .collect(Collectors.toList());
        }
    }

    @Data
    @RequiredArgsConstructor
    public static class Foo {
        private final Integer id;
    }
}

根据上面的用例,如果缓存生效,理论上 ServiceImpl#getByIds 方法应该只会被调用一次,但是实际上被调用了两次。

不过问题在于,第二次调用缓存确实生效了,因为前后两次获取的对象都一个对象,然而即使缓存全部命中,依然查了库,只不过第二次查的时候传入的就是一个空集合了。

这个问题在于 CacheableContainer#get 方法有问题,缓存 key 全部命中没有及时返回数据,而是继续查库了:

@SuppressWarnings("unchecked")
@Override
public Map<K, ?> get(Collection<K> keys) {
    CacheObject<K> current = getCurrentCache();
    Map<K, Object> caches = current.getAll(keys);

    // all keys are not cached?
    if (caches.isEmpty()) {
        if (log.isDebugEnabled()) {
            log.debug("get none cached keys [{}] from container [{}]", keys, container.getNamespace());
        }
        Map<K, Object> values = (Map<K, Object>)container.get(keys);
        current.putAll(values);
        return values;
    }

    // some keys are cached?
    keys = keys.stream()
        .filter(k -> !caches.containsKey(k)).collect(Collectors.toSet());
  	// FIX:这个地方如果缓存全部命中,那么 keys 应该是空的,此时应该直接返回数据,而不是继续执行下去
  
    if (log.isDebugEnabled()) {
        log.debug("get none cached keys [{}] from container [{}]", keys, container.getNamespace());
    }
    Map<K, Object> values = (Map<K, Object>)container.get(keys);
    current.putAll(values);
    // merge cached values and none cached values
    caches.putAll(values);
    return caches;
}

解决方案

目前应该所有的缓存机制都有这个问题,本周内我会发 2.8.2 修复它,在那之前,你可以先用下面这几种方案临时解决一下:

  • 将循环单次填充改为一次批量填充;

  • 使用 SpringCache 或者其他的缓存机制;

  • 在本地项目建一个同路径且同名的类 cn.crane4j.core.cache.CacheableContainer,调整里面的 get 方法,然后覆盖 Crane4j 源码里面的 CacheableContainer

    @Override
    public Map<K, ?> get(Collection<K> keys) {
        CacheObject<K> current = getCurrentCache();
        Map<K, Object> caches = current.getAll(keys);
    
        // all keys are not cached?
        if (caches.isEmpty()) {
            if (log.isDebugEnabled()) {
                log.debug("get none cached keys [{}] from container [{}]", keys, container.getNamespace());
            }
            Map<K, Object> values = (Map<K, Object>)container.get(keys);
            current.putAll(values);
            return values;
        }
    
        // some keys are cached?
        keys = keys.stream()
            .filter(k -> !caches.containsKey(k))
            .collect(Collectors.toSet());
        if (keys.isEmpty()) {
            return caches;
        }
    
        // get none cached keys from container
        if (log.isDebugEnabled()) {
            log.debug("get none cached keys [{}] from container [{}]", keys, container.getNamespace());
        }
        Map<K, Object> values = (Map<K, Object>)container.get(keys);
        current.putAll(values);
        // merge cached values and none cached values
        caches.putAll(values);
        return caches;
    }

你说的是另一个问题,命中了缓存,但是还是会查DB,且ID都变成了NULL

我碰到的是另一种情况,刚好两种情况都被我碰到了:

cn.crane4j.core.cache.GuavaCacheManager.DefaultCacheFactory#getCache

public Cache<Object, Object> getCache(Long expireTime, TimeUnit timeUnit) {
            Asserts.isNotEquals(expireTime, 0L, "Expire time must not be 0");
            if (expireTime > 1) {
                return CacheBuilder.newBuilder()
                    .expireAfterWrite(expireTime, timeUnit)
                    .build();
            }
            // if expire time less than 0, use weak keys and weak values
            return CacheBuilder.newBuilder()
                .weakKeys().weakValues()
                .build();
        }

这块有点问题,过期时间为1或者不设置过期时间都会走下面的build,weakKeys会导致cache的hash计算使用的com.google.common.base.Equivalence.Identity,其中会使用System.identityHashCode(o); 计算hash,会导致同一个值两次的hashcode不一致,所以命中不到cache

你说的是另一个问题,命中了缓存,但是还是会查DB,且ID都变成了NULL

我碰到的是另一种情况,刚好两种情况都被我碰到了:

cn.crane4j.core.cache.GuavaCacheManager.DefaultCacheFactory#getCache

public Cache<Object, Object> getCache(Long expireTime, TimeUnit timeUnit) {
            Asserts.isNotEquals(expireTime, 0L, "Expire time must not be 0");
            if (expireTime > 1) {
                return CacheBuilder.newBuilder()
                    .expireAfterWrite(expireTime, timeUnit)
                    .build();
            }
            // if expire time less than 0, use weak keys and weak values
            return CacheBuilder.newBuilder()
                .weakKeys().weakValues()
                .build();
        }

这块有点问题,过期时间为1或者不设置过期时间都会走下面的build,weakKeys会导致cache的hash计算使用的com.google.common.base.Equivalence.Identity,其中会使用System.identityHashCode(o); 计算hash,会导致同一个值两次的hashcode不一致,所以命中不到cache

好家伙,坑算是给你都踩出来了。

我研究了一下,System.identityHashCode(o) 返回是原始的 hashCode,即等同于调用了对象未被重写的 hashCode,这个相当于通过 “==” 直接比较内存地址了。

也就是说,如果某个类重写了 hashCode,那么这个类的两个实例即使 equals 返回 true,且 hashCode 相等,但是通过 System.identityHashCode(o) 计算得到的依然是不相等的值。

这个问题最终会导致对于常用的 Key 类型 —— 比如 Integer、Long、String —— 来说,一旦值超过了常量池的范围,那么即使理论上 Key 应该可以命中缓存,但是实际上依然无法从 GuavaCache 中获取到缓存的数据。比如 Integer.valueOf(10086)Integer.valueOf(10086) 在匹配的时候就会被认为不是一个 Key。

这个问题在 2.8.2 会一并修复。

这个问题会在关联到 PR 以后关闭,在确认修复代码提交之前先开着吧。