從零開始手寫緩存框架(二)redis expire 過時原理及實現

前言

咱們在 從零手寫 cache 框架(一)實現固定大小的緩存 中已經初步實現了咱們的 cache。java

本節,讓咱們來一塊兒學習一下如何實現相似 redis 中的 expire 過時功能。git

image

過時是一個很是有用的特性,好比我但願登陸信息放到 redis 中,30min 以後失效;或者單日的累計信息放在 redis 中,在天天的凌晨自動清空。github

代碼實現

接口

咱們首先來定義一下接口。redis

主要有兩個:一個是多久以後過時,一個是在何時過時。緩存

public interface ICache<K, V> extends Map<K, V> {

    /**
     * 設置過時時間
     * (1)若是 key 不存在,則什麼都不作。
     * (2)暫時不提供新建 key 指定過時時間的方式,會破壞原來的方法。
     *
     * 會作什麼:
     * 相似於 redis
     * (1)惰性刪除。
     * 在執行下面的方法時,若是過時則進行刪除。
     * {@link ICache#get(Object)} 獲取
     * {@link ICache#values()} 獲取全部值
     * {@link ICache#entrySet()} 獲取全部明細
     *
     * 【數據的不一致性】
     * 調用其餘方法,可能獲得的不是使用者的預期結果,由於此時的 expire 信息可能沒有被及時更新。
     * 好比
     * {@link ICache#isEmpty()} 是否爲空
     * {@link ICache#size()} 當前大小
     * 同時會致使以 size() 做爲過時條件的問題。
     *
     * 解決方案:考慮添加 refresh 等方法,暫時不作一致性的考慮。
     * 對於實際的使用,咱們更關心 K/V 的信息。
     *
     * (2)定時刪除
     * 啓動一個定時任務。每次隨機選擇指定大小的 key 進行是否過時判斷。
     * 相似於 redis,爲了簡化,能夠考慮設定超時時間,頻率與超時時間成反比。
     *
     * 其餘拓展性考慮:
     * 後期考慮提供原子性操做,保證事務性。暫時不作考慮。
     * 此處默認使用 TTL 做爲比較的基準,暫時不想支持 LastAccessTime 的淘汰策略。會增長複雜度。
     * 若是增長 lastAccessTime 過時,本方法能夠不作修改。
     *
     * @param key         key
     * @param timeInMills 毫秒時間以後過時
     * @return this
     * @since 0.0.3
     */
    ICache<K, V> expire(final K key, final long timeInMills);

    /**
     * 在指定的時間過時
     * @param key key
     * @param timeInMills 時間戳
     * @return this
     * @since 0.0.3
     */
    ICache<K, V> expireAt(final K key, final long timeInMills);

}

代碼實現

爲了便於處理,咱們將多久以後過時,進行計算。將兩個問題變成同一個問題,在何時過時的問題。框架

核心的代碼,主要仍是看 cacheExpire 接口。ide

@Override
public ICache<K, V> expire(K key, long timeInMills) {
    long expireTime = System.currentTimeMillis() + timeInMills;
    return this.expireAt(key, expireTime);
}

@Override
public ICache<K, V> expireAt(K key, long timeInMills) {
    this.cacheExpire.expire(key, timeInMills);
    return this;
}

緩存過時

這裏爲了便於後期拓展,對於過時的處理定義爲接口,便於後期靈活替換。性能

接口

其中 expire(final K key, final long expireAt); 就是咱們方法中調用的地方。學習

refershExpire 屬於惰性刪除,須要進行刷新時才考慮,咱們後面講解。測試

public interface ICacheExpire<K,V> {

    /**
     * 指定過時信息
     * @param key key
     * @param expireAt 何時過時
     * @since 0.0.3
     */
    void expire(final K key, final long expireAt);

    /**
     * 惰性刪除中須要處理的 keys
     * @param keyList keys
     * @since 0.0.3
     */
    void refreshExpire(final Collection<K> keyList);

}

expire 實現原理

其實過時的實思路也比較簡單:咱們能夠開啓一個定時任務,好比 1 秒鐘作一次輪訓,將過時的信息清空。

過時信息的存儲

/**
 * 過時 map
 *
 * 空間換時間
 * @since 0.0.3
 */
private final Map<K, Long> expireMap = new HashMap<>();

@Override
public void expire(K key, long expireAt) {
    expireMap.put(key, expireAt);
}

咱們定義一個 map,key 是對應的要過時的信息,value 存儲的是過時時間。

輪詢清理

咱們固定 100ms 清理一次,每次最多清理 100 個。

/**
 * 單次清空的數量限制
 * @since 0.0.3
 */
private static final int LIMIT = 100;

/**
 * 緩存實現
 * @since 0.0.3
 */
private final ICache<K,V> cache;
/**
 * 線程執行類
 * @since 0.0.3
 */
private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor();
public CacheExpire(ICache<K, V> cache) {
    this.cache = cache;
    this.init();
}
/**
 * 初始化任務
 * @since 0.0.3
 */
private void init() {
    EXECUTOR_SERVICE.scheduleAtFixedRate(new ExpireThread(), 100, 100, TimeUnit.MILLISECONDS);
}

這裏定義了一個單線程,用於執行清空任務。

image

清空任務

這個很是簡單,遍歷過時數據,判斷對應的時間,若是已經到期了,則執行清空操做。

爲了不單次執行時間過長,最多隻處理 100 條。

/**
 * 定時執行任務
 * @since 0.0.3
 */
private class ExpireThread implements Runnable {
    @Override
    public void run() {
        //1.判斷是否爲空
        if(MapUtil.isEmpty(expireMap)) {
            return;
        }
        //2. 獲取 key 進行處理
        int count = 0;
        for(Map.Entry<K, Long> entry : expireMap.entrySet()) {
            if(count >= LIMIT) {
                return;
            }
            expireKey(entry);
            count++;
        }
    }
}

/**
 * 執行過時操做
 * @param entry 明細
 * @since 0.0.3
 */
private void expireKey(Map.Entry<K, Long> entry) {
    final K key = entry.getKey();
    final Long expireAt = entry.getValue();
    // 刪除的邏輯處理
    long currentTime = System.currentTimeMillis();
    if(currentTime >= expireAt) {
        expireMap.remove(key);
        // 再移除緩存,後續能夠經過惰性刪除作補償
        cache.remove(key);
    }
}

清空的優化思路

若是過時的應用場景很少,那麼常常輪訓的意義實際不大。

好比咱們的任務 99% 都是在凌晨清空數據,白天不管怎麼輪詢,純粹是浪費資源。

那有沒有什麼方法,能夠快速的判斷有沒有須要處理的過時元素呢?

答案是有的,那就是排序的 MAP。

咱們換一種思路,讓過時的時間作 key,相同時間的須要過時的信息放在一個列表中,做爲 value。

而後對過時時間排序,輪詢的時候就能夠快速判斷出是否有過時的信息了。

public class CacheExpireSort<K,V> implements ICacheExpire<K,V> {

    /**
     * 單次清空的數量限制
     * @since 0.0.3
     */
    private static final int LIMIT = 100;

    /**
     * 排序緩存存儲
     *
     * 使用按照時間排序的緩存處理。
     * @since 0.0.3
     */
    private final Map<Long, List<K>> sortMap = new TreeMap<>(new Comparator<Long>() {
        @Override
        public int compare(Long o1, Long o2) {
            return (int) (o1-o2);
        }
    });

    /**
     * 過時 map
     *
     * 空間換時間
     * @since 0.0.3
     */
    private final Map<K, Long> expireMap = new HashMap<>();

    /**
     * 緩存實現
     * @since 0.0.3
     */
    private final ICache<K,V> cache;

    /**
     * 線程執行類
     * @since 0.0.3
     */
    private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor();

    public CacheExpireSort(ICache<K, V> cache) {
        this.cache = cache;
        this.init();
    }

    /**
     * 初始化任務
     * @since 0.0.3
     */
    private void init() {
        EXECUTOR_SERVICE.scheduleAtFixedRate(new ExpireThread(), 1, 1, TimeUnit.SECONDS);
    }

    /**
     * 定時執行任務
     * @since 0.0.3
     */
    private class ExpireThread implements Runnable {
        @Override
        public void run() {
            //1.判斷是否爲空
            if(MapUtil.isEmpty(sortMap)) {
                return;
            }

            //2. 獲取 key 進行處理
            int count = 0;
            for(Map.Entry<Long, List<K>> entry : sortMap.entrySet()) {
                final Long expireAt = entry.getKey();
                List<K> expireKeys = entry.getValue();

                // 判斷隊列是否爲空
                if(CollectionUtil.isEmpty(expireKeys)) {
                    sortMap.remove(expireAt);
                    continue;
                }
                if(count >= LIMIT) {
                    return;
                }

                // 刪除的邏輯處理
                long currentTime = System.currentTimeMillis();
                if(currentTime >= expireAt) {
                    Iterator<K> iterator = expireKeys.iterator();
                    while (iterator.hasNext()) {
                        K key = iterator.next();
                        // 先移除自己
                        iterator.remove();
                        expireMap.remove(key);

                        // 再移除緩存,後續能夠經過惰性刪除作補償
                        cache.remove(key);

                        count++;
                    }
                } else {
                    // 直接跳過,沒有過時的信息
                    return;
                }
            }
        }
    }

    @Override
    public void expire(K key, long expireAt) {
        List<K> keys = sortMap.get(expireAt);
        if(keys == null) {
            keys = new ArrayList<>();
        }
        keys.add(key);

        // 設置對應的信息
        sortMap.put(expireAt, keys);
        expireMap.put(key, expireAt);
    }
}

看起來是切實可行的,這樣能夠下降輪詢的壓力。

這裏其實使用空間換取時間,以爲後面能夠作一下改進,這種方法性能應該仍是不錯的。

不過我並無採用這個方案,主要是考慮到惰性刪除的問題,這樣會麻煩一些,後續考慮持續改善下這個方案。

惰性刪除

出現的緣由

相似於 redis,咱們採用定時刪除的方案,就有一個問題:可能數據清理的不及時。

那當咱們查詢時,可能獲取到到是髒數據。

因而就有一些人就想了,當咱們關心某些數據時,纔對數據作對應的刪除判斷操做,這樣壓力會小不少。

算是一種折中方案。

image

須要惰性刪除的方法

通常就是各類查詢方法,好比咱們獲取 key 對應的值時

@Override
@SuppressWarnings("unchecked")
public V get(Object key) {
    //1. 刷新全部過時信息
    K genericKey = (K) key;
    this.cacheExpire.refreshExpire(Collections.singletonList(genericKey));
    return map.get(key);
}

咱們在獲取以前,先作一次數據的刷新。

刷新的實現

實現原理也很是簡單,就是一個循環,而後做刪除便可。

這裏加了一個小的優化:選擇數量少的做爲外循環。

循環集合的時間複雜度是 O(n), map.get() 的時間複雜度是 O(1);

@Override
public void refreshExpire(Collection<K> keyList) {
    if(CollectionUtil.isEmpty(keyList)) {
        return;
    }
    // 判斷大小,小的做爲外循環。通常都是過時的 keys 比較小。
    if(keyList.size() <= expireMap.size()) {
        for(K key : keyList) {
            expireKey(key);
        }
    } else {
        for(Map.Entry<K, Long> entry : expireMap.entrySet()) {
            this.expireKey(entry);
        }
    }
}

測試

上面的代碼寫完以後,咱們就能夠驗證一下了。

ICache<String, String> cache = CacheBs.<String,String>newInstance()
        .size(3)
        .build();
cache.put("1", "1");
cache.put("2", "2");

cache.expire("1", 10);
Assert.assertEquals(2, cache.size());

TimeUnit.MILLISECONDS.sleep(50);
Assert.assertEquals(1, cache.size());

System.out.println(cache.keySet());

結果也符合咱們的預期。

小結

到這裏,一個相似於 redis 的 expire 過時功能,算是基本實現了。

固然,還有不少優化的地方。

好比爲了後續添加各類監聽器方便,我對全部須要刷新的地方調整爲使用字節碼+註解的方式,而不是在每個須要的方法中添加刷新方法。

下一節,咱們將共同窗習下如何實現各類監聽器。

對你有幫助的話,歡迎點贊評論收藏關注一波走起~

你的鼓勵,是我最大的動力~

深刻學習

原文地址

Cache Travel-09-從零手寫 cache 之 redis expire 過時實現原理

相關文章
相關標籤/搜索