咱們在 從零手寫 cache 框架(一)實現固定大小的緩存 中已經初步實現了咱們的 cache。java
本節,讓咱們來一塊兒學習一下如何實現相似 redis 中的 expire 過時功能。git
過時是一個很是有用的特性,好比我但願登陸信息放到 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); }
其實過時的實思路也比較簡單:咱們能夠開啓一個定時任務,好比 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); }
這裏定義了一個單線程,用於執行清空任務。
這個很是簡單,遍歷過時數據,判斷對應的時間,若是已經到期了,則執行清空操做。
爲了不單次執行時間過長,最多隻處理 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,咱們採用定時刪除的方案,就有一個問題:可能數據清理的不及時。
那當咱們查詢時,可能獲取到到是髒數據。
因而就有一些人就想了,當咱們關心某些數據時,纔對數據作對應的刪除判斷操做,這樣壓力會小不少。
算是一種折中方案。
通常就是各類查詢方法,好比咱們獲取 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 過時功能,算是基本實現了。
固然,還有不少優化的地方。
好比爲了後續添加各類監聽器方便,我對全部須要刷新的地方調整爲使用字節碼+註解的方式,而不是在每個須要的方法中添加刷新方法。
下一節,咱們將共同窗習下如何實現各類監聽器。
對你有幫助的話,歡迎點贊評論收藏關注一波走起~
你的鼓勵,是我最大的動力~