你應該知道的快取進化史

咖啡拿鐵發表於2018-08-16

1.背景

本文是上週去技術沙龍聽了一下愛奇藝的Java快取之路有感寫出來的。先簡單介紹一下愛奇藝的java快取道路的發展吧。

你應該知道的快取進化史
可以看見圖中分為幾個階段:

  • 第一階段:資料同步加redis

通過訊息佇列進行資料同步至redis,然後Java應用直接去取快取 這個階段優點是:由於是使用的分散式快取,所以資料更新快。缺點也比較明顯:依賴Redis的穩定性,一旦redis掛了,整個快取系統不可用,造成快取雪崩,所有請求打到DB。

  • 第二,三階段:JavaMap到Guava cache

這個階段使用程式內快取作為一級快取,redis作為二級。優點:不受外部系統影響,其他系統掛了,依然能使用。缺點:程式內快取無法像分散式快取那樣做到實時更新。由於java記憶體有限,必定快取得設定大小,然後有些快取會被淘汰,就會有命中率的問題。

  • 第四階段: Guava Cache重新整理

為了解決上面的問題,利用Guava Cache可以設定寫後重新整理時間,進行重新整理。解決了一直不更新的問題,但是依然沒有解決實時重新整理。

  • 第五階段: 外部快取非同步重新整理
    你應該知道的快取進化史

這個階段擴充套件了Guava Cache,利用redis作為訊息佇列通知機制,通知其他java應用程式進行重新整理。

這裡簡單介紹一下愛奇藝快取發展的五個階段,當然還有一些其他的優化,比如GC調優,快取穿透,快取覆蓋的一些優化等等。有興趣的同學可以關注公眾號,聯絡我進行交流。

原始社會 - 查庫

上面說的是愛奇藝的一個進化線路,但是在大家的一般開發過程中,第一步一般都沒有redis,而是直接查庫。

在流量不大的時候,查資料庫或者讀取檔案是最為方便,也能完全滿足我們的業務要求。

古代社會 - HashMap

當我們應用有一定流量之後或者查詢資料庫特別頻繁,這個時候就可以祭出我們的java中自帶的HashMap或者ConcurrentHashMap。我們可以在程式碼中這麼寫:

public class CustomerService {
    private HashMap<String,String> hashMap = new HashMap<>();
    private CustomerMapper customerMapper;
    public String getCustomer(String name){
        String customer = hashMap.get(name);
        if ( customer == null){
            customer = customerMapper.get(name);
            hashMap.put(name,customer);
        }
        return customer;
    }
}
複製程式碼

但是這樣做就有個問題HashMap無法進行資料淘汰,記憶體會無限制的增長,所以hashMap很快也被淘汰了。當然並不是說他完全就沒用,就像我們古代社會也不是所有的東西都是過時的,比如我們中華名族的傳統美德是永不過時的,就像這個hashMap一樣的可以在某些場景下作為快取,當不需要淘汰機制的時候,比如我們利用反射,如果我們每次都通過反射去搜尋Method,field,效能必定低效,這時我們用HashMap將其快取起來,效能能提升很多。

近代社會 - LRUHashMap

在古代社會中難住我們的問題無法進行資料淘汰,這樣會導致我們記憶體無限膨脹,顯然我們是不可以接受的。有人就說我把一些資料給淘汰掉唄,這樣不就對了,但是怎麼淘汰呢?隨機淘汰嗎?當然不行,試想一下你剛把A裝載進快取,下一次要訪問的時候就被淘汰了,那又會訪問我們的資料庫了,那我們要快取幹嘛呢?

所以聰明的人們就發明了幾種淘汰演算法,下面列舉下常見的三種FIFO,LRU,LFU(還有一些ARC,MRU感興趣的可以自行搜尋):

  • FIFO:先進先出,在這種淘汰演算法中,先進入快取的會先被淘汰。這種可謂是最簡單的了,但是會導致我們命中率很低。試想一下我們如果有個訪問頻率很高的資料是所有資料第一個訪問的,而那些不是很高的是後面再訪問的,那這樣就會把我們的首個資料但是他的訪問頻率很高給擠出。
  • LRU:最近最少使用演算法。在這種演算法中避免了上面的問題,每次訪問資料都會將其放在我們的隊尾,如果需要淘汰資料,就只需要淘汰隊首即可。但是這個依然有個問題,如果有個資料在1個小時的前59分鐘訪問了1萬次(可見這是個熱點資料),再後一分鐘沒有訪問這個資料,但是有其他的資料訪問,就導致了我們這個熱點資料被淘汰。
  • LFU:最近最少頻率使用。在這種演算法中又對上面進行了優化,利用額外的空間記錄每個資料的使用頻率,然後選出頻率最低進行淘汰。這樣就避免了LRU不能處理時間段的問題。

上面列舉了三種淘汰策略,對於這三種,實現成本是一個比一個高,同樣的命中率也是一個比一個好。而我們一般來說選擇的方案居中即可,即實現成本不是太高,而命中率也還行的LRU,如何實現一個LRUMap呢?我們可以通過繼承LinkedHashMap,重寫removeEldestEntry方法,即可完成一個簡單的LRUMap。

class LRUMap extends LinkedHashMap {

        private final int max;
        private Object lock;

        public LRUMap(int max, Object lock) {
            //無需擴容
            super((int) (max * 1.4f), 0.75f, true);
            this.max = max;
            this.lock = lock;
        }

        /**
         * 重寫LinkedHashMap的removeEldestEntry方法即可
         * 在Put的時候判斷,如果為true,就會刪除最老的
         * @param eldest
         * @return
         */
        @Override
        protected boolean removeEldestEntry(Map.Entry eldest) {
            return size() > max;
        }

        public Object getValue(Object key) {
            synchronized (lock) {
                return get(key);
            }
        }
        public void putValue(Object key, Object value) {
            synchronized (lock) {
                put(key, value);
            }
        }

       

        public boolean removeValue(Object key) {
            synchronized (lock) {
                return remove(key) != null;
            }
        }
        public boolean removeAll(){
            clear();
            return true;
        }
    }
複製程式碼

在LinkedHashMap中維護了一個entry(用來放key和value的物件)連結串列。在每一次get或者put的時候都會把插入的新entry,或查詢到的老entry放在我們連結串列末尾。 可以注意到我們在構造方法中,設定的大小特意設定到max*1.4,在下面的removeEldestEntry方法中只需要size>max就淘汰,這樣我們這個map永遠也走不到擴容的邏輯了,通過重寫LinkedHashMap,幾個簡單的方法我們實現了我們的LruMap。

現代社會 - Guava cache

在近代社會中已經發明出來了LRUMap,用來進行快取資料的淘汰,但是有幾個問題:

  • 鎖競爭嚴重,可以看見我的程式碼中,Lock是全域性鎖,在方法級別上面的,當呼叫量較大時,效能必然會比較低。
  • 不支援過期時間
  • 不支援自動重新整理

所以谷歌的大佬們對於這些問題,按捺不住了,發明了Guava cache,在Guava cache中你可以如下面的程式碼一樣,輕鬆使用:

public static void main(String[] args) throws ExecutionException {
        LoadingCache<String, String> cache = CacheBuilder.newBuilder()
                .maximumSize(100)
                //寫之後30ms過期
                .expireAfterWrite(30L, TimeUnit.MILLISECONDS)
                //訪問之後30ms過期
                .expireAfterAccess(30L, TimeUnit.MILLISECONDS)
                //20ms之後重新整理
                .refreshAfterWrite(20L, TimeUnit.MILLISECONDS)
                //開啟weakKey key 當啟動垃圾回收時,該快取也被回收
                .weakKeys()
                .build(createCacheLoader());
        System.out.println(cache.get("hello"));
        cache.put("hello1", "我是hello1");
        System.out.println(cache.get("hello1"));
        cache.put("hello1", "我是hello2");
        System.out.println(cache.get("hello1"));
    }
    public static com.google.common.cache.CacheLoader<String, String> createCacheLoader() {
        return new com.google.common.cache.CacheLoader<String, String>() {
            @Override
            public String load(String key) throws Exception {
                return key;
            }
        };
    }
複製程式碼

我將會從guava cache原理中,解釋guava cache是如何解決LRUMap的幾個問題的。

鎖競爭

guava cache採用了類似ConcurrentHashMap的思想,分段加鎖,在每個段裡面各自負責自己的淘汰的事情。在Guava根據一定的演算法進行分段,這裡要說明的是,如果段太少那競爭依然很嚴重,如果段太多會容易出現隨機淘汰,比如大小為100的,給他分100個段,那也就是讓每個資料都獨佔一個段,而每個段會自己處理淘汰的過程,所以會出現隨機淘汰。在guava cache中通過如下程式碼,計算出應該如何分段。

    int segmentShift = 0;
    int segmentCount = 1;
    while (segmentCount < concurrencyLevel && (!evictsBySize() || segmentCount * 20 <= maxWeight)) {
      ++segmentShift;
      segmentCount <<= 1;
    }
複製程式碼

上面segmentCount就是我們最後的分段數,其保證了每個段至少10個Entry。如果沒有設定concurrencyLevel這個引數,那麼預設就會是4,最後分段數也最多為4,例如我們size為100,會分為4段,每段最大的size是25。 在guava cache中對於寫操作直接加鎖,對於讀操作,如果讀取的資料沒有過期,且已經載入就緒,不需要進行加鎖,如果沒有讀到會再次加鎖進行二次讀,如果還沒有需要進行快取載入,也就是通過我們配置的CacheLoader,我這裡配置的是直接返回Key,在業務中通常配置從資料庫中查詢。 如下圖所示:

你應該知道的快取進化史

過期時間

相比於LRUMap多了兩種過期時間,一個是寫後多久過期expireAfterWrite,一個是讀後多久過期expireAfterAccess。很有意思的事情是,在guava cache中對於過期的Entry並沒有馬上過期(也就是並沒有後臺執行緒一直在掃),而是通過進行讀寫操作的時候進行過期處理,這樣做的好處是避免後臺執行緒掃描的時候進行全域性加鎖。看下面的程式碼:

public static void main(String[] args) throws ExecutionException, InterruptedException {
        Cache<String, String> cache = CacheBuilder.newBuilder()
                .maximumSize(100)
                //寫之後5s過期
                .expireAfterWrite(5, TimeUnit.MILLISECONDS)
                .concurrencyLevel(1)
                .build();
        cache.put("hello1", "我是hello1");
        cache.put("hello2", "我是hello2");
        cache.put("hello3", "我是hello3");
        cache.put("hello4", "我是hello4");
        //至少睡眠5ms
        Thread.sleep(5);
        System.out.println(cache.size());
        cache.put("hello5", "我是hello5");
        System.out.println(cache.size());
    }
輸出:
4 
1
複製程式碼

從這個結果中我們知道,在put的時候才進行的過期處理。特別注意的是我上面concurrencyLevel(1)我這裡將分段最大設定為1,不然不會出現這個實驗效果的,在上面一節中已經說過,我們是以段位單位進行過期處理。在每個Segment中維護了兩個佇列:


    final Queue<ReferenceEntry<K, V>> writeQueue;

  
    final Queue<ReferenceEntry<K, V>> accessQueue;

複製程式碼

writeQueue維護了寫佇列,隊頭代表著寫得早的資料,隊尾代表寫得晚的資料。 accessQueue維護了訪問佇列,和LRU一樣,用來我們進行訪問時間的淘汰,如果當這個Segment超過最大容量,比如我們上面所說的25,超過之後,就會把accessQueue這個佇列的第一個元素進行淘汰。

void expireEntries(long now) {
      drainRecencyQueue();

      ReferenceEntry<K, V> e;
      while ((e = writeQueue.peek()) != null && map.isExpired(e, now)) {
        if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
          throw new AssertionError();
        }
      }
      while ((e = accessQueue.peek()) != null && map.isExpired(e, now)) {
        if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
          throw new AssertionError();
        }
      }
    }
複製程式碼

上面就是guava cache處理過期Entries的過程,會對兩個佇列一次進行peek操作,如果過期就進行刪除。一般處理過期Entries可以在我們的put操作的前後,或者讀取資料時發現過期了,然後進行整個Segment的過期處理,又或者進行二次讀lockedGetOrLoad操作的時候呼叫。

void evictEntries(ReferenceEntry<K, V> newest) {
      ///... 省略無用程式碼

      while (totalWeight > maxSegmentWeight) {
        ReferenceEntry<K, V> e = getNextEvictable();
        if (!removeEntry(e, e.getHash(), RemovalCause.SIZE)) {
          throw new AssertionError();
        }
      }
    }
/**
**返回accessQueue的entry
**/
ReferenceEntry<K, V> getNextEvictable() {
      for (ReferenceEntry<K, V> e : accessQueue) {
        int weight = e.getValueReference().getWeight();
        if (weight > 0) {
          return e;
        }
      }
      throw new AssertionError();
    }
複製程式碼

上面是我們驅逐Entry的時候的程式碼,可以看見訪問的是accessQueue對其隊頭進行驅逐。而驅逐策略一般是在對segment中的元素髮生變化時進行呼叫,比如插入操作,更新操作,載入資料操作。

自動重新整理

自動重新整理操作,在guava cache中實現相對比較簡單,直接通過查詢,判斷其是否滿足重新整理條件,進行重新整理。

其他特性

在Guava cache中還有一些其他特性:

虛引用

在Guava cache中,key和value都能進行虛引用的設定,在Segment中的有兩個引用佇列:

    final @Nullable ReferenceQueue<K> keyReferenceQueue;

  
    final @Nullable ReferenceQueue<V> valueReferenceQueue;
複製程式碼

這兩個佇列用來記錄被回收的引用,其中每個佇列記錄了每個被回收的Entry的hash,這樣回收了之後通過這個佇列中的hash值就能把以前的Entry進行刪除。

刪除監聽器

在guava cache中,當有資料被淘汰時,但是你不知道他到底是過期,還是被驅逐,還是因為虛引用的物件被回收?這個時候你可以呼叫這個方法removalListener(RemovalListener listener)新增監聽器進行資料淘汰的監聽,可以打日誌或者一些其他處理,可以用來進行資料淘汰分析。

在RemovalCause記錄了所有被淘汰的原因:被使用者刪除,被使用者替代,過期,驅逐收集,由於大小淘汰。

guava cache的總結

細細品讀guava cache的原始碼總結下來,其實就是一個效能不錯的,api豐富的LRU Map。愛奇藝的快取的發展也是基於此之上,通過對guava cache的二次開發,讓其可以進行java應用服務之間的快取更新。

走向未來-caffeine

guava cache的功能的確是很強大,滿足了絕大多數的人的需求,但是其本質上還是LRU的一層封裝,所以在眾多其他較為優良的淘汰演算法中就相形見絀了。而caffeine cache實現了W-TinyLFU(LFU+LRU演算法的變種)。下面是不同演算法的命中率的比較:

你應該知道的快取進化史

其中Optimal是最理想的命中率,LRU和其他演算法相比的確是個弟弟。而我們的W-TinyLFU 是最接近理想命中率的。當然不僅僅是命中率caffeine優於了guava cache,在讀寫吞吐量上面也是完爆guava cache。

你應該知道的快取進化史

這個時候你肯定會好奇為啥這麼caffeine這麼牛逼呢?彆著急下面慢慢給你道來。

W-TinyLFU

上面已經說過了傳統的LFU是怎麼一回事。在LFU中只要資料訪問模式的概率分佈隨時間保持不變時,其命中率就能變得非常高。這裡我還是拿愛奇藝舉例,比如有部新劇出來了,我們使用LFU給他快取下來,這部新劇在這幾天大概訪問了幾億次,這個訪問頻率也在我們的LFU中記錄了幾億次。但是新劇總會過氣的,比如一個月之後這個新劇的前幾集其實已經過氣了,但是他的訪問量的確是太高了,其他的電視劇根本無法淘汰這個新劇,所以在這種模式下是有侷限性。所以各種LFU的變種出現了,基於時間週期進行衰減,或者在最近某個時間段內的頻率。同樣的LFU也會使用額外空間記錄每一個資料訪問的頻率,即使資料沒有在快取中也需要記錄,所以需要維護的額外空間很大。

可以試想我們對這個維護空間建立一個hashMap,每個資料項都會存在這個hashMap中,當資料量特別大的時候,這個hashMap也會特別大。

再回到LRU,我們的LRU也不是那麼一無是處,LRU可以很好的應對突發流量的情況,因為他不需要累計資料頻率。

所以W-TinyLFU結合了LRU和LFU,以及其他的演算法的一些特點。

頻率記錄

首先要說到的就是頻率記錄的問題,我們要實現的目標是利用有限的空間可以記錄隨時間變化的訪問頻率。在W-TinyLFU中使用Count-Min Sketch記錄我們的訪問頻率,而這個也是布隆過濾器的一種變種。如下圖所示:

你應該知道的快取進化史
如果需要記錄一個值,那我們需要通過多種Hash演算法對其進行處理hash,然後在對應的hash演算法的記錄中+1,為什麼需要多種hash演算法呢?由於這是一個壓縮演算法必定會出現衝突,比如我們建立一個Long的陣列,通過計算出每個資料的hash的位置。比如張三和李四,他們兩有可能hash值都是相同,比如都是1那Long[1]這個位置就會增加相應的頻率,張三訪問1萬次,李四訪問1次那Long[1]這個位置就是1萬零1,如果取李四的訪問評率的時候就會取出是1萬零1,但是李四命名只訪問了1次啊,為了解決這個問題,所以用了多個hash演算法可以理解為long[][]二維陣列的一個概念,比如在第一個演算法張三和李四衝突了,但是在第二個,第三個中很大的概率不衝突,比如一個演算法大概有1%的概率衝突,那四個演算法一起衝突的概率是1%的四次方。通過這個模式我們取李四的訪問率的時候取所有演算法中,李四訪問最低頻率的次數。所以他的名字叫Count-Min Sketch。

你應該知道的快取進化史

你應該知道的快取進化史

這裡和以前的做個對比,簡單的舉個例子:如果一個hashMap來記錄這個頻率,如果我有100個資料,那這個HashMap就得儲存100個這個資料的訪問頻率。哪怕我這個快取的容量是1,因為Lfu的規則我必須全部記錄這個100個資料的訪問頻率。如果有更多的資料我就有記錄更多的。

在Count-Min Sketch中,我這裡直接說caffeine中的實現吧(在FrequencySketch這個類中),如果你的快取大小是100,他會生成一個long陣列大小是和100最接近的2的冪的數,也就是128。而這個陣列將會記錄我們的訪問頻率。在caffeine中他規則頻率最大為15,15的二進位制位1111,總共是4位,而Long型是64位。所以每個Long型可以放16種演算法,但是caffeine並沒有這麼做,只用了四種hash演算法,每個Long型被分為四段,每段裡面儲存的是四個演算法的頻率。這樣做的好處是可以進一步減少Hash衝突,原先128大小的hash,就變成了128X4。

一個Long的結構如下:

你應該知道的快取進化史
我們的4個段分為A,B,C,D,在後面我也會這麼叫它們。而每個段裡面的四個演算法我叫他s1,s2,s3,s4。下面舉個例子如果要新增一個訪問50的數字頻率應該怎麼做?我們這裡用size=100來舉例。

  1. 首先確定50這個hash是在哪個段裡面,通過hash & 3必定能獲得小於4的數字,假設hash & 3=0,那就在A段。
  2. 對50的hash再用其他hash演算法再做一次hash,得到long陣列的位置。假設用s1演算法得到1,s2演算法得到3,s3演算法得到4,s4演算法得到0。
  3. 然後在long[1]的A段裡面的s1位置進行+1,簡稱1As1加1,然後在3As2加1,在4As3加1,在0As4加1。

你應該知道的快取進化史

這個時候有人會質疑頻率最大為15的這個是否太小?沒關係在這個演算法中,比如size等於100,如果他全域性提升了1000次就會全域性除以2衰減,衰減之後也可以繼續增加,這個演算法再W-TinyLFU的論文中證明了其可以較好的適應時間段的訪問頻率。

讀寫效能

在guava cache中我們說過其讀寫操作中夾雜著過期時間的處理,也就是你在一次Put操作中有可能還會做淘汰操作,所以其讀寫效能會受到一定影響,可以看上面的圖中,caffeine的確在讀寫操作上面完爆guava cache。主要是因為在caffeine,對這些事件的操作是通過非同步操作,他將事件提交至佇列,這裡的佇列的資料結構是RingBuffer,不清楚的可以看看這篇文章,你應該知道的高效能無鎖佇列Disruptor。然後通過會通過預設的ForkJoinPool.commonPool(),或者自己配置執行緒池,進行取佇列操作,然後在進行後續的淘汰,過期操作。

當然讀寫也是有不同的佇列,在caffeine中認為快取讀比寫多很多,所以對於寫操作是所有執行緒共享一個Ringbuffer。

你應該知道的快取進化史

對於讀操作比寫操作更加頻繁,進一步減少競爭,其為每個執行緒配備了一個RingBuffer:

你應該知道的快取進化史

資料淘汰策略

在caffeine所有的資料都在ConcurrentHashMap中,這個和guava cache不同,guava cache是自己實現了個類似ConcurrentHashMap的結構。在caffeine中有三個記錄引用的LRU佇列:

  • Eden佇列:在caffeine中規定只能為快取容量的%1,如果size=100,那這個佇列的有效大小就等於1。這個佇列中記錄的是新到的資料,防止突發流量由於之前沒有訪問頻率,而導致被淘汰。比如有一部新劇上線,在最開始其實是沒有訪問頻率的,防止上線之後被其他快取淘汰出去,而加入這個區域。伊甸區,最舒服最安逸的區域,在這裡很難被其他資料淘汰。

  • Probation佇列:叫做緩刑佇列,在這個佇列就代表你的資料相對比較冷,馬上就要被淘汰了。這個有效大小為size減去eden減去protected。

  • Protected佇列:在這個佇列中,可以稍微放心一下了,你暫時不會被淘汰,但是別急,如果Probation佇列沒有資料了或者Protected資料滿了,你也將會被面臨淘汰的尷尬局面。當然想要變成這個佇列,需要把Probation訪問一次之後,就會提升為Protected佇列。這個有效大小為(size減去eden) X 80% 如果size =100,就會是79。

這三個佇列關係如下:

你應該知道的快取進化史

  1. 所有的新資料都會進入Eden。
  2. Eden滿了,淘汰進入Probation。
  3. 如果在Probation中訪問了其中某個資料,則這個資料升級為Protected。
  4. 如果Protected滿了又會繼續降級為Probation。

對於發生資料淘汰的時候,會從Probation中進行淘汰,會把這個佇列中的資料隊頭稱為受害者,這個隊頭肯定是最早進入的,按照LRU佇列的演算法的話那他其實他就應該被淘汰,但是在這裡只能叫他受害者,這個佇列是緩刑佇列,代表馬上要給他行刑了。這裡會取出隊尾叫候選者,也叫攻擊者。這裡受害者會和攻擊者做PK,通過我們的Count-Min Sketch中的記錄的頻率資料有以下幾個判斷:

  • 如果攻擊者大於受害者,那麼受害者就直接被淘汰。
  • 如果攻擊者<=5,那麼直接淘汰攻擊者。這個邏輯在他的註釋中有解釋:
    你應該知道的快取進化史
    他認為設定一個預熱的門檻會讓整體命中率更高。
  • 其他情況,隨機淘汰。

如何使用

對於熟悉Guava的玩家來說如果擔心有切換成本,那麼你完全就多慮了,caffeine的api借鑑了Guava的api,可以發現其基本一模一樣。

public static void main(String[] args) {
        Cache<String, String> cache = Caffeine.newBuilder()
                .expireAfterWrite(1, TimeUnit.SECONDS)
                .expireAfterAccess(1,TimeUnit.SECONDS)
                .maximumSize(10)
                .build();
        cache.put("hello","hello");
    }
複製程式碼

順便一提的是,越來越多的開源框架都放棄了Guava cache,比如Spring5。在業務上我也自己曾經比較過Guava cache和caffeine最終選擇了caffeine,線上上也有不錯的效果。所以不用擔心caffeine不成熟,沒人使用。

最後

本文主要講了愛奇藝的快取之路和本地快取的一個發展歷史(從古至今到未來),以及每一種快取的實現基本原理。當然要使用好快取光是這些僅僅不夠,比如本地快取如何在其他地方更改了之後同步更新,分散式快取,多級快取等等。後面也會專門寫一節介紹這個如何用好快取。對於Guava cache和caffeine的原理後面也會專門抽出時間寫這兩個的原始碼分析,如果感興趣的朋友可以關注公眾號第一時間查閱更新文章。

最後這篇文章被我收錄於JGrowing,一個全面,優秀,由社群一起共建的Java學習路線,如果您想參與開源專案的維護,可以一起共建,github地址為:github.com/javagrowing… 麻煩給個小星星喲。

如果你覺得這篇文章對你有文章,可以關注我的技術公眾號,你的關注和轉發是對我最大的支援,O(∩_∩)O

你應該知道的快取進化史

相關文章