Spring Cache 缺陷,我好像有解決方案了

程式設計師小黑發表於2020-04-07

Spring Cache 缺陷

Spring Cache 是一個非常優秀的快取元件。

但是在使用 Spring Cache 的過程當中,小黑同學也遇到了一些痛點。

比如,現在有一個需求:通過多個 userId 來批量獲取使用者資訊。

方案 1

此時,我們的程式碼可能是這樣:

List<User> users = ids.stream().map(id -> {
    return getUserById(id);
})
.collect(Collectors.toList());

@Cacheable(key = "#p0", unless = "#result == null")
public User getUserById(Long id) {
	// ···
}
複製程式碼

這種寫法的缺點在於:

在 for 迴圈中操作 redis。如果資料命中快取還好,一旦快取沒有命中,則會訪問資料庫。

方案 2

也有的同學可能會這樣做:

@Cacheable(key = "#ids.hash")
public Collection<User> getUsersByIds(Collection<Long> ids) {
	// ···
}
複製程式碼

這種做法的問題是:

快取是基於 id 列表的 hashcode ,只有在 id 列表的 hashcode 值相等的情況下,快取才會命中。而且,一旦列表中的其中一個資料被修改,整個列表快取都要被清除。

例如:

第一次請求 id 列表是 1,2,3,

第二次請求的 id 列表為 1,2,4

在這種情況下,前後兩次的快取不能共享。

如果 id 為 1 的資料發生了改變,那麼,這兩次請求的快取都要被清空

看看 Spring 官方是怎麼說的

Spring Issue:

github.com/spring-proj…

github.com/spring-proj…

image.png

簡單翻譯一下,具體內容讀者可以自行查閱相關 issue。

譯文:

謝謝你的報告。快取抽象沒有這種狀態的概念,如果你返回一個集合,那就是你要求在快取中儲存的東西。也沒有什麼強迫您為給定的快取保留相同的項型別,所以這種假設並不適合這樣的高階抽象。

我的理解是,對於 Spring Cache 這種高階抽象框架來說,Cache 是基於方法的,如果方法返回 Collection,那整個 Collection 就是需要被快取的內容。

我的解決方案

糾結了好久,小黑同學還是決定自己來造個輪子。

那我想要達到什麼樣的效果呢?

我希望對於這種根據多個 key 批量獲取快取的操作,可以先根據單個 key 從快取中查詢,如果快取中不存在,就去載入資料,同時再將資料放到快取中。

talk is cheap,show me the code

廢話不多說,直接上原始碼:

github.com/shenjianeng…

簡單介紹一下整體的思路:

  • 核心介面

    • com.github.shenjianeng.easycache.core.Cache

    • com.github.shenjianeng.easycache.core.MultiCacheLoader

Cache 介面

Cache 介面定義了一些通用的快取操作。和大部分 Cache 框架不同是,這裡支援根據 key 批量獲取快取。

/**
 * 根據 keys 快取中獲取,快取中不存在,則返回null
 */
@NonNull
Map<K, V> getIfPresent(@NonNull Iterable<K> keys);


/**
 * 根據 keys 從快取中獲取,如果快取中不存在,呼叫 {@link MultiCacheLoader#loadCache(java.util.Collection)} 載入資料,並新增到快取中
 */
@NonNull
Map<K, V> getOrLoadIfAbsent(@NonNull Iterable<K> keys);
複製程式碼

MultiCacheLoader 介面

@FunctionalInterface
public interface MultiCacheLoader<K, V> {

    @NonNull
    Map<K, V> loadCache(@NonNull Collection<K> keys);

    default V loadCache(K key) {
        Map<K, V> map = loadCache(Collections.singleton(key));
        if (CollectionUtils.isEmpty(map)) {
            return null;
        }
        return map.get(key);
    }
}
複製程式碼

MultiCacheLoader 是一個函式式介面。在呼叫 Cache#getOrLoadIfAbsent 方法時,如果快取不存在,就會通過 MultiCacheLoader 來載入資料,然後加資料放到快取中。

RedisCache

RedisCache 是現在 Cache 介面的唯一實現。正如其類名一樣,這是基於 redis 的快取實現。

先說一下大致的實現思路:

  1. 使用 redis 的 mget 命令,批量獲取快取。為了保證效率,每次最多批量獲取 20 個。
  2. 如果有資料不在快取中,則判斷是否需要自動載入資料,如果需要則通過 MultiCacheLoader 載入資料
  3. 將資料存放到快取中。同時通過維護一個 zset 來儲存已知的 cache key,用於清除快取使用。

廢話不多說,直接上原始碼。

private Map<K, V> doGetOrLoadIfAbsent(Iterable<K> keys, boolean loadIfAbsent) {
    List<String> cacheKeyList = buildCacheKey(keys);
    List<List<String>> partitions = Lists.partition(cacheKeyList, MAX_BATCH_KEY_SIZE);

    List<V> valueList = Lists.newArrayListWithExpectedSize(cacheKeyList.size());

    for (List<String> partition : partitions) {
        // Get multiple keys. Values are returned in the order of the requested keys.
        List<V> values = (List<V>) redisTemplate.opsForValue().multiGet(partition);
        valueList.addAll(values);
    }

    List<K> keysList = Lists.newArrayList(keys);
    List<K> missedKeyList = Lists.newArrayList();

    Map<K, V> map = Maps.newHashMapWithExpectedSize(partitions.size());


    for (int i = 0; i < valueList.size(); i++) {
        V v = valueList.get(i);
        K k = keysList.get(i);
        if (v != null) {
            map.put(k, v);
        } else {
            missedKeyList.add(k);
        }
    }

    if (loadIfAbsent) {
        Map<K, V> missValueMap = multiCacheLoader.loadCache(missedKeyList);

        put(missValueMap);

        map.putAll(missValueMap);
    }

    return map;
}
複製程式碼

快取清除方法實現:

public void evictAll() {
    Set<Serializable> serializables = redisTemplate.opsForZSet().rangeByScore(knownKeysName, 0, 0);

    if (!CollectionUtils.isEmpty(serializables)) {
        List<String> cacheKeys = Lists.newArrayListWithExpectedSize(serializables.size());
        serializables.forEach(serializable -> {
            if (serializable instanceof String) {
                cacheKeys.add((String) serializable);
            }
        });
        redisTemplate.delete(cacheKeys);
        redisTemplate.opsForZSet().remove(knownKeysName, cacheKeys);
    }
}
複製程式碼

再多說幾句

更多原始碼細節,如果讀者感興趣,可以自行閱讀原始碼:easy-cache

歡迎大家 fork 體驗,或者評論區留言探討,寫的不好,請多多指教~~

未來計劃:

  • 支援快取 null 值
  • 支援 annotation 的宣告式快取

相關文章