天天看點

SpringBoot Cache 實作二級緩存

作者:Java機械師

二級緩存介紹

  1. 二級緩存分為本地緩存和遠端緩存,也可稱為記憶體緩存和網絡緩存
  2. 常見的流行緩存架構
  3. 本地緩存:Caffeine,Guava Cache
  4. 遠端緩存:Redis,MemCache
  5. 二級緩存的通路流程
  6. 二級緩存的優勢與問題
  7. 優勢:二級緩存優先使用本地緩存,通路資料非常快,有效減少和遠端緩存之間的資料交換,節約網絡開銷
  8. 問題:分布式環境下本地緩存存在一緻性問題,本地緩存變更後需要通知其他節點重新整理本地緩存,這對一緻性要求高的場景可能不能很好的适應

SpringBoot Cache 元件

  1. SpringBoot Cache 元件提供了一套緩存管理的接口以及聲明式使用的緩存的注解
  2. 引入 SpringBoot Cache
  3. xml複制代碼
  4. <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>
  5. 如何內建第三方緩存架構到 Cache 元件
  6. 實作 Cache 接口,适配第三方緩存架構的操作,實作 CacheManager 接口,提供緩存管理器的 Bean
  7. SpringBoot Cache 預設提供了 Caffeine、ehcache 等常見緩存架構的管理器,引入相關依賴後即可使用
  8. 引入 Caffeine
  9. xml複制代碼
  10. <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency>
  11. SpringBoot Redis 提供了 Redis 緩存的實作及管理器
  12. 引入 Redis 緩存、RedisTemplate
  13. xml複制代碼
  14. <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
  15. SpringBoot Cache 聲明式緩存注解
  16. @Cacheable:執行方法前,先從緩存中擷取,沒有擷取到才執行方法,并将其結果更新到緩存
  17. @CachePut:執行方法後,将其結果更新到緩存
  18. @CacheEvict:執行方法後,清除緩存
  19. @Caching:組合前三個注解
  20. @Cacheable 注解的常用屬性:
  21. vbnet複制代碼
  22. cacheNames/value:緩存名稱 key:緩存資料的 key,預設使用方法參數值,支援 SpEL keyGenerator:指定 key 的生成器,和 key 屬性二選一 cacheManager:指定使用的緩存管理器。 condition:在方法執行開始前檢查,在符合 condition 時,進行緩存操作 unless:在方法執行完成後檢查,在符合 unless 時,不進行緩存操作 sync:是否使用同步模式,同步模式下,多個線程同時未命中一個 key 的資料,将阻塞競争執行方法
  23. SpEL 支援的表達式

本地緩存 Caffeine

Caffeine 介紹

  1. Caffeine 是繼 Guava Cache 之後,在 SpringBoot 2.x 中預設內建的緩存架構
  2. Caffeine 使用了 Window TinyLFU 淘汰政策,緩存命中率極佳,被稱為現代高性能緩存庫之王
  3. 建立一個 Caffeine Cache
  4. java複制代碼
  5. Cache<String, Object> cache = Caffeine.newBuilder().build();

Caffeine 記憶體淘汰政策

  1. FIFO:先進先出,命中率低
  2. LRU:最近最久未使用,不能應對冷門突發流量,會導緻熱點資料被淘汰
  3. LFU:最近最少使用,需要維護使用頻率,占用記憶體空間,
  4. W-TinyLFU:LFU 的變種,綜合了 LRU LFU 的長處,高命中率,低記憶體占用

Caffeine 緩存失效政策

  1. 基于容量大小
  2. 根據最大容量
  3. java複制代碼
  4. Cache<String, Object> cache = Caffeine.newBuilder() .maximumSize(10000) .build();
  5. 根據權重
  6. java複制代碼
  7. Cache<String, Object> cache = Caffeine.newBuilder() .maximumWeight(10000) .weigher((Weigher<String, Object>) (s, o) -> { // 根據不同對象計算權重 return 0; }) .build();
  8. 基于引用類型
  9. 基于弱引用,當不存在強引用時淘汰
  10. java複制代碼
  11. Cache<String, Object> cache = Caffeine.newBuilder() .weakKeys() .weakValues() .build();
  12. 基于軟引用,當不存在強引用且記憶體不足時淘汰
  13. java複制代碼
  14. Cache<String, Object> cache = Caffeine.newBuilder() .softValues() .build();
  15. 基于過期時間
  16. expireAfterWrite,寫入後一定時間後過期
  17. java複制代碼
  18. Cache<String, Object> cache = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .build();
  19. expireAfterAccess(long, TimeUnit),通路後一定時間後過期,一直通路則一直不過期
  20. expireAfter(Expiry),自定義時間的計算方式

Caffeine 線程池

  1. Caffeine 預設使用 ForkJoinPool.commonPool()
  2. Caffeine 線程池可通過 executor 方法設定

Caffeine 名額統計

  1. Caffeine 通過配置 recordStats 方法開啟名額統計,通過緩存的 stats 方法擷取資訊
  2. Caffeine 名額統計的内容有:命中率,加載資料耗時,緩存數量相關等

Caffeine Cache 的種類

  1. 普通 Cache
  2. java複制代碼
  3. Cache<String, Object> cache = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .build(); // 存入 cache.put("key1", "123"); // 取出 Object key1Obj = cache.getIfPresent("key1"); // 清除 cache.invalidate("key1"); // 清除全部 cache.invalidateAll();
  4. 異步 Cache
  5. 響應結果通過 CompletableFuture 包裝,利用線程池異步執行
  6. java複制代碼
  7. AsyncCache<String, Object> asyncCache = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .buildAsync(); // 存入 asyncCache.put("key1", CompletableFuture.supplyAsync(() -> "123")); // 取出 CompletableFuture<Object> key1Future = asyncCache.getIfPresent("key1"); try { Object key1Obj = key1Future.get(); } catch (InterruptedException | ExecutionException e) { // } // 清除 asyncCache.synchronous().invalidate("key1"); // 清除全部 asyncCache.synchronous().invalidateAll();
  8. Loading Cache
  9. 和普通緩存使用方式一緻
  10. 在緩存未命中時,自動加載資料到緩存,需要設定加載資料的回調,比如從資料庫查詢資料
  11. java複制代碼
  12. LoadingCache<String, Object> cache = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .build(key -> { // 擷取業務資料 return "Data From DB"; });
  13. 異步 Loading Cache
  14. 和異步緩存使用方式一緻
  15. 在緩存未命中時,自動加載資料到緩存,與 Loading Cache 不同的是,加載資料是異步的
  16. java複制代碼
  17. // 使用 AsyncCache 的線程池異步加載 AsyncLoadingCache<String, Object> asyncCache0 = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .buildAsync(key -> { // 擷取業務資料 return "Data From DB"; }); // 指定加載使用的線程池 AsyncLoadingCache<String, Object> asyncCache1 = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> { // 異步擷取業務資料 return "Data From DB"; }, otherExecutor));
  18. 注意:AsyncLoadingCache 不支援弱引用和軟引用相關淘汰政策

Caffeine 自動重新整理機制

  1. Caffeine 可通過 refreshAfterWrite 設定定時重新整理
  2. 必須是指定了 CacheLoader 的緩存,即 LoadingCache 和 AsyncLoadingCache
  3. java複制代碼
  4. LoadingCache<String, Object> cache = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .refreshAfterWrite(3, TimeUnit.SECONDS) .build(key -> { // 擷取業務資料 return "Data From DB"; });
  5. refreshAfterWrite 是一種定時重新整理,key 過期時并不一定會立即重新整理

實作二級緩存

配置類 DLCacheProperties

java複制代碼@Data
@ConfigurationProperties(prefix = "uni-boot.cache.dl")
public class DLCacheProperties {

    /**
     * 是否存儲 null 值
     */
    private boolean allowNullValues = true;

    /**
     * 過期時間,為 0 表示不過期,預設 30 分鐘
     * 機關:毫秒
     */
    private long defaultExpiration = 30 * 60 * 1000;

    /**
     * 針對 cacheName 設定過期時間,為 0 表示不過期
     * 機關:毫秒
     */
    private Map<String, Long> cacheExpirationMap;

    /**
     * 本地緩存 caffeine 配置
     */
    private LocalConfig local = new LocalConfig();

    /**
     * 遠端緩存 redis 配置
     */
    private RemoteConfig remote = new RemoteConfig();


    @Data
    public static class LocalConfig {

        /**
         * 初始化大小,為 0 表示預設
         */
        private int initialCapacity;

        /**
         * 最大緩存個數,為 0 表示預設
         * 預設最多 5 萬條
         */
        private long maximumSize = 10000L;
    }

    @Data
    public static class RemoteConfig {

        /**
         * Redis pub/sub 緩存重新整理通知主題
         */
        private String syncTopic = "cache:dl:refresh:topic";
    }
}
           

緩存實作 DLCache

本地緩存基于 Caffeine,遠端緩存使用 Redis

實作 SpringBoot Cache 的抽象類,AbstractValueAdaptingCache

java複制代碼@Slf4j
@Getter
public class DLCache extends AbstractValueAdaptingCache {

    private final String name;
    private final long expiration;
    private final DLCacheProperties cacheProperties;
    private final Cache<String, Object> caffeineCache;
    private final RedisTemplate<String, Object> redisTemplate;

    public DLCache(String name, long expiration, DLCacheProperties cacheProperties,
                   Cache<String, Object> caffeineCache, RedisTemplate<String, Object> redisTemplate) {
        super(cacheProperties.isAllowNullValues());
        this.name = name;
        this.expiration = expiration;
        this.cacheProperties = cacheProperties;
        this.caffeineCache = caffeineCache;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public Object getNativeCache() {
        return this;
    }

    @Override
    protected Object lookup(Object key) {
        String redisKey = getRedisKey(key);
        Object val;
        val = caffeineCache.getIfPresent(key);
        // val 是 toStoreValue 包裝過的值,為 null 則 key 不存在
        // 因為存儲的 null 值被包裝成了 DLCacheNullVal.INSTANCE
        if (ObjectUtil.isNotNull(val)) {
            log.debug("DLCache local get cache, key:{}, value:{}", key, val);
            return val;
        }
        val = redisTemplate.opsForValue().get(redisKey);
        if (ObjectUtil.isNotNull(val)) {
            log.debug("DLCache remote get cache, key:{}, value:{}", key, val);
            caffeineCache.put(key.toString(), val);
            return val;
        }
        return val;
    }

    @SuppressWarnings("unchecked")
    @Override
    public <T> T get(Object key, Callable<T> valueLoader) {
        T val;
        val = (T) lookup(key);
        if (ObjectUtil.isNotNull(val)) {
            return val;
        }
        // 雙檢鎖
        synchronized (key.toString().intern()) {
            val = (T) lookup(key);
            if (ObjectUtil.isNotNull(val)) {
                return val;
            }
            try {
                // 攔截的業務方法
                val = valueLoader.call();
                // 加入緩存
                put(key, val);
            } catch (Exception e) {
                throw new DLCacheException("DLCache valueLoader fail", e);
            }
            return val;
        }
    }

    @Override
    public void put(Object key, Object value) {
        putRemote(key, value);
        sendSyncMsg(key);
        putLocal(key, value);
    }

    @Override
    public void evict(Object key) {
        // 先清理 redis 再清理 caffeine
        clearRemote(key);
        sendSyncMsg(key);
        clearLocal(key);
    }

    @Override
    public void clear() {
        // 先清理 redis 再清理 caffeine
        clearRemote(null);
        sendSyncMsg(null);
        clearLocal(null);
    }

    private void sendSyncMsg(Object key) {
        String syncTopic = cacheProperties.getRemote().getSyncTopic();
        DLCacheRefreshMsg refreshMsg = new DLCacheRefreshMsg(name, key);
        // 加入 SELF_MSG_MAP 防止自身節點重複處理
        DLCacheRefreshListener.SELF_MSG_MAP.add(refreshMsg);
        redisTemplate.convertAndSend(syncTopic, refreshMsg);
    }

    private void putLocal(Object key, Object value) {
        // toStoreValue 包裝 null 值
        caffeineCache.put(key.toString(), toStoreValue(value));
    }

    private void putRemote(Object key, Object value) {
        if (expiration > 0) {
            // toStoreValue 包裝 null 值
            redisTemplate.opsForValue().set(getRedisKey(key), toStoreValue(value), expiration, TimeUnit.MILLISECONDS);
            return;
        }
        redisTemplate.opsForValue().set(getRedisKey(key), toStoreValue(value));
    }

    public void clearRemote(Object key) {
        if (ObjectUtil.isNull(key)) {
            Set<String> keys = redisTemplate.keys(getRedisKey("*"));
            if (ObjectUtil.isNotEmpty(keys)) {
                keys.forEach(redisTemplate::delete);
            }
            return;
        }
        redisTemplate.delete(getRedisKey(key));
    }

    public void clearLocal(Object key) {
        if (ObjectUtil.isNull(key)) {
            caffeineCache.invalidateAll();
            return;
        }
        caffeineCache.invalidate(key);
    }

    /**
     * 檢查是否允許緩存 null
     *
     * @param value 緩存值
     * @return 不為空則 true,為空但允許則 false,否則異常
     */
    private boolean checkValNotNull(Object value) {
        if (ObjectUtil.isNotNull(value)) {
            return true;
        }
        if (isAllowNullValues() && ObjectUtil.isNull(value)) {
            return false;
        }
        // val 不能為空,但傳了空
        throw new DLCacheException("Check null val is not allowed");
    }

    @Override
    protected Object fromStoreValue(Object storeValue) {
        if (isAllowNullValues() && DLCacheNullVal.INSTANCE.equals(storeValue)) {
            return null;
        }
        return storeValue;
    }

    @Override
    protected Object toStoreValue(Object userValue) {
        if (!checkValNotNull(userValue)) {
            return DLCacheNullVal.INSTANCE;
        }
        return userValue;
    }

    /**
     * 擷取 redis 完整 key
     */
    private String getRedisKey(Object key) {
        // 雙冒号,與 spring cache 預設一緻
        return this.name.concat("::").concat(key.toString());
    }

    /**
     * 在緩存時代替 null 值,以區分是 key 不存在還是 val 為 null
     */
    @Data
    public static class DLCacheNullVal {
        public static final DLCacheNullVal INSTANCE = new DLCacheNullVal();
        private String desc = "nullVal";
    }
}
           
注意:需要區分緩存 get 到 null 值和 key 不存在,是以使用了 DLCacheNullVal 來代替 null 值

緩存管理器 DLCacheManager

緩存管理器

實作 SpringBoot Cache 的 CacheManager 接口

java複制代碼@Slf4j
@RequiredArgsConstructor
public class DLCacheManager implements CacheManager {

    private final ConcurrentHashMap<String, DLCache> cacheMap = new ConcurrentHashMap<>();

    private final DLCacheProperties cacheProperties;
    private final RedisTemplate<String, Object> redisTemplate;

    @Override
    public DLCache getCache(String name) {
        return cacheMap.computeIfAbsent(name, (o) -> {
            DLCache dlCache = buildCache(o);
            log.debug("Create DLCache instance, name:{}", o);
            return dlCache;
        });
    }

    private DLCache buildCache(String name) {
        Caffeine<Object, Object> caffeine = Caffeine.newBuilder();
        // 設定過期時間 expireAfterWrite
        long expiration = 0;
        // 擷取針對 cache name 設定的過期時間
        Map<String, Long> cacheExpirationMap = cacheProperties.getCacheExpirationMap();
        if (ObjectUtil.isNotEmpty(cacheExpirationMap) && cacheExpirationMap.get(name) > 0) {
            expiration = cacheExpirationMap.get(name);
        } else if (cacheProperties.getDefaultExpiration() > 0) {
            expiration = cacheProperties.getDefaultExpiration();
        }
        if (expiration > 0) {
            caffeine.expireAfterWrite(expiration, TimeUnit.MILLISECONDS);
        }
        // 設定參數
        LocalConfig localConfig = cacheProperties.getLocal();
        if (ObjectUtil.isNotNull(localConfig.getInitialCapacity()) && localConfig.getInitialCapacity() > 0) {
            caffeine.initialCapacity(localConfig.getInitialCapacity());

        }
        if (ObjectUtil.isNotNull(localConfig.getMaximumSize()) && localConfig.getMaximumSize() > 0) {
            caffeine.maximumSize(localConfig.getMaximumSize());
        }
        return new DLCache(name, expiration, cacheProperties, caffeine.build(), redisTemplate);
    }

    @Override
    public Collection<String> getCacheNames() {
        return Collections.unmodifiableSet(cacheMap.keySet());
    }
}
           

緩存重新整理監聽器

緩存消息

java複制代碼@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class DLCacheRefreshMsg {

    private String cacheName;

    private Object key;
}
           

緩存重新整理消息監聽

java複制代碼@Slf4j
@RequiredArgsConstructor
@Component
public class DLCacheRefreshListener implements MessageListener, InitializingBean {

    public static final ConcurrentHashSet<DLCacheRefreshMsg> SELF_MSG_MAP = new ConcurrentHashSet<>();

    private final DLCacheManager dlCacheManager;
    private final DLCacheProperties cacheProperties;
    private final RedisMessageListenerContainer listenerContainer;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        // 序列化出重新整理消息
        DLCacheRefreshMsg refreshMsg = (DLCacheRefreshMsg) RedisUtil.getTemplate().getValueSerializer().deserialize(message.getBody());
        if (ObjectUtil.isNull(refreshMsg)) {
            return;
        }
        // 判斷是不是自身節點發出
        if (SELF_MSG_MAP.contains(refreshMsg)) {
            SELF_MSG_MAP.remove(refreshMsg);
            return;
        }
        log.debug("DLCache refresh local, cache name:{}, key:{}", refreshMsg.getCacheName(), refreshMsg.getKey());
        // 清理本地緩存
        dlCacheManager.getCache(refreshMsg.getCacheName()).clearLocal(refreshMsg.getKey());
    }

    @Override
    public void afterPropertiesSet() {
        // 注冊到 RedisMessageListenerContainer
        listenerContainer.addMessageListener(this, new ChannelTopic(cacheProperties.getRemote().getSyncTopic()));
    }
}
           

使用二級緩存

注入 DLCacheManager

java複制代碼@Bean(name = "dlCacheManager")
    public DLCacheManager dlCacheManager(DLCacheProperties cacheProperties, RedisTemplate<String, Object> redisTemplate) {
        return new DLCacheManager(cacheProperties, redisTemplate);
    }
           

使用 @Cacheable 配合 DLCacheManager

java複制代碼@ApiOperation("測試 @Cacheable")
@Cacheable(cacheNames = "test", key = "'dl'", cacheManager = "dlCacheManager")
@PostMapping("test_cacheable")
public String testCacheable() {
    log.info("testCacheable 執行");
    return "Cacheable";
}

@ApiOperation("測試 @Cacheable null 值")
@Cacheable(cacheNames = "test", key = "'dl'", cacheManager = "dlCacheManager")
@PostMapping("test_cacheable_null")
public String testCacheableNull() {
    log.info("testCacheableNull 執行");
    return null;
}

@ApiOperation("測試 @CachePut")
@CachePut(cacheNames = "test", key = "'dl'", cacheManager = "dlCacheManager")
@PostMapping("test_put")
public String testPut() {
    return "Put";
}

@ApiOperation("測試 @CacheEvict")
@CacheEvict(cacheNames = "test", key = "'dl'", cacheManager = "dlCacheManager")
@PostMapping("test_evict")
public String testEvict() {
    return "Evict";
}