Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘

京東雲發表於2022-09-20

1 前言

之前旁邊的小夥伴問我熱點資料相關問題,在給他粗略地講解一波redis資料傾斜的案例之後,自己也順道回顧了一些關於熱點資料處理的方法論,同時也想起去年所學習JD開源專案hotkey——專門用來解決熱點資料問題的框架。在這裡結合兩者所關聯到的知識點,透過幾個小圖和部分粗略的講解,來讓大家瞭解相關方法論以及hotkey的原始碼解析。

2 Redis資料傾斜

2.1 定義與危害

先說說資料傾斜的定義,借用百度詞條的解釋:

對於叢集系統,一般快取是分散式的,即不同節點負責一定範圍的快取資料。我們把快取資料分散度不夠,導致大量的快取資料集中到了一臺或者幾臺服務節點上,稱為資料傾斜。一般來說資料傾斜是由於負載均衡實施的效果不好引起的。

從上面的定義中可以得知,資料傾斜的原因一般是因為LB的效果不好,導致部分節點資料量非常集中。

那這又會有什麼危害呢?

如果發生了資料傾斜,那麼儲存了大量資料,或者是儲存了熱點資料的例項的處理壓力就會增大,速度變慢,甚至還可能會引起這個例項的記憶體資源耗盡,從而崩潰。這是我們在應用切片叢集時要避免的。

2.2 資料傾斜的分類

2.2.1 資料量傾斜(寫入傾斜)

1.圖示


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


如圖,在某些情況下,例項上的資料分佈不均衡,某個例項上的資料特別多。

2.bigkey導致傾斜

某個例項上正好儲存了 bigkey。bigkey 的 value 值很大(String 型別),或者是 bigkey 儲存了大量集合元素(集合型別),會導致這個例項的資料量增加,記憶體資源消耗也相應增加。

應對方法

  • 在業務層生成資料時,要儘量避免把過多的資料儲存在同一個鍵值對中。

  • 如果 bigkey 正好是集合型別,還有一個方法,就是把 bigkey 拆分成很多個小的集合型別資料,分散儲存在不同的例項上。

3.Slot分配不均導致傾斜

先簡單的介紹一下slot的概念,slot其實全名是Hash Slot(雜湊槽),在Redis Cluster切片叢集中一共有16384 個 Slot,這些雜湊槽類似於資料分割槽,每個鍵值對都會根據它的 key,被對映到一個雜湊槽中。Redis Cluster 方案採用雜湊槽來處理資料和例項之間的對映關係。

一張圖來解釋,資料、雜湊槽、例項這三者的對映分佈情況。


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


這裡的CRC16(city)%16384可以簡單的理解為將key1根據CRC16演算法取hash值然後對slot個數取模,得到的就是slot位置為14484,他所對應的例項節點是第三個。

運維在構建切片叢集時候,需要手動分配雜湊槽,並且把16384 個槽都分配完,否則 Redis 叢集無法正常工作。由於是手動分配,則可能會導致部分例項所分配的slot過多,導致資料傾斜。

應對方法

使用CLUSTER SLOTS 命令來查

看slot分配情況,使用CLUSTER SETSLOT,CLUSTER GETKEYSINSLOT,MIGRATE這三個命令來進行slot資料的遷移,具體內容不再這裡細說,感興趣的同學可以自行學習一下。

4.Hash Tag導致傾斜
  • Hash Tag 定義 :指當一個key包含 {} 的時候,就不對整個key做hash,而僅對 {} 包括的字串做hash。

  • 假設hash演算法為sha1。對user:{user1}:ids和user:{user1}:tweets,其hash值都等同於sha1(user1)。

  • Hash Tag 優勢 :如果不同 key 的 Hash Tag 內容都是一樣的,那麼,這些 key 對應的資料會被對映到同一個 Slot 中,同時會被分配到同一個例項上。

  • Hash Tag 劣勢 :如果不合理使用,會導致大量的資料可能被集中到一個例項上發生資料傾斜,叢集中的負載不均衡。

2.2.2 資料訪問傾斜(讀取傾斜-熱key問題)

一般來說資料訪問傾斜就是熱key問題導致的,如何處理redis熱key問題也是面試中常會問到的。所以瞭解相關概念及方法論也是不可或缺的一環。

1.圖示


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


如圖,雖然每個叢集例項上的資料量相差不大,但是某個例項上的資料是熱點資料,被訪問得非常頻繁。

但是為啥會有熱點資料的產生呢?

2.產生熱key的原因及危害

1)使用者消費的資料遠大於生產的資料(熱賣商品、熱點新聞、熱點評論、明星直播)。

在日常工作生活中一些突發的事件,例如:雙十一期間某些熱門商品的降價促銷,當這其中的某一件商品被數萬次點選瀏覽或者購買時,會形成一個較大的需求量,這種情況下就會造成熱點問題。

同理,被大量刊發、瀏覽的熱點新聞、熱點評論、明星直播等,這些典型的讀多寫少的場景也會產生熱點問題。

2)請求分片集中,超過單 Server 的效能極限。

在服務端讀資料進行訪問時,往往會對資料進行分片切分,此過程中會在某一主機 Server 上對相應的 Key 進行訪問,當訪問超過 Server 極限時,就會導致熱點 Key 問題的產生。

如果熱點過於集中,熱點 Key 的快取過多,超過目前的快取容量時,就會導致快取分片服務被打垮現象的產生。當快取服務崩潰後,此時再有請求產生,會快取到後臺 DB 上,由於DB 本身效能較弱,在面臨大請求時很容易發生請求穿透現象,會進一步導致雪崩現象,嚴重影響裝置的效能。

3.常用的熱key問題解決辦法:

解決方案一: 備份熱key

可以把熱點資料複製多份,在每一個資料副本的 key 中增加一個隨機字尾,讓它和其它副本資料不會被對映到同一個 Slot 中。

這裡相當於把一份資料複製到其他例項上,這樣在訪問的時候也增加隨機字首,將對一個例項的訪問壓力,均攤到其他例項上

例如:我們在放入快取時就將對應業務的快取key拆分成多個不同的key。如下圖所示,我們首先在更新快取的一側,將key拆成N份,比如一個key名字叫做”good_100”,那我們就可以把它拆成四份,“good_100_copy1”、“good_100_copy2”、“good_100_copy3”、“good_100_copy4”,每次更新和新增時都需要去改動這N個key,這一步就是拆key。


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


對於service端來講,我們就需要想辦法儘量將自己訪問的流量足夠的均勻。

如何給自己即將訪問的熱key上加入字尾?幾種辦法,根據本機的ip或mac地址做hash,之後的值與拆key的數量做取餘,最終決定拼接成什麼樣的key字尾,從而打到哪臺機器上;服務啟動時的一個隨機數對拆key的數量做取餘。

虛擬碼如下:


const M = N * 2
//生成隨機數
random = GenRandom(0, M)
//構造備份新key
bakHotKey = hotKey + “_” + random
data = redis.GET(bakHotKey)
if data == NULL {
  data = GetFromDB()
  redis.SET(bakHotKey, expireTime + GenRandom(0,5))
}


解決方案二: 本地快取+動態計算自動發現熱點快取


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


該方案透過主動發現熱點並對其進行儲存來解決熱點 Key 的問題。首先 Client 也會訪問 SLB,並且透過 SLB 將各種請求分發至 Proxy 中,Proxy 會按照基於路由的方式將請求轉發至後端的 Redis 中。

在熱點 key 的解決上是採用在服務端增加快取的方式進行。具體來說就是在 Proxy 上增加本地快取,本地快取採用 LRU 演算法來快取熱點資料,後端節點增加熱點資料計算模組來返回熱點資料。

Proxy 架構的主要有以下優點:

  • Proxy 本地快取熱點,讀能力可水平擴充套件

  • DB 節點定時計算熱點資料集合

  • DB 反饋 Proxy 熱點資料

  • 對客戶端完全透明,不需做任何相容

熱點資料的發現與儲存


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


對於熱點資料的發現,首先會在一個週期內對 Key 進行請求統計,在達到請求量級後會對熱點 Key 進行熱點定位,並將所有的熱點 Key 放入一個小的 LRU 連結串列內,在透過 Proxy 請求進行訪問時,若 Redis 發現待訪點是一個熱點,就會進入一個反饋階段,同時對該資料進行標記。

可以使用一個etcd或者zk叢集來儲存反饋的熱點資料,然後本地所有節點監聽該熱點資料,進而載入到本地JVM快取中。

熱點資料的獲取


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


在熱點 Key 的處理上主要分為寫入跟讀取兩種形式,在資料寫入過程當 SLB 收到資料 K1 並將其透過某一個 Proxy 寫入一個 Redis,完成資料的寫入。

假若經過後端熱點模組計算發現 K1 成為熱點 key 後, Proxy 會將該熱點進行快取,當下次客戶端再進行訪問 K1 時,可以不經 Redis。

最後由於 proxy 是可以水平擴充的,因此可以任意增強熱點資料的訪問能力。

最佳成熟方案: JD開源hotKey這是目前較為成熟的自動探測熱key、分散式一致性快取解決方案。原理就是在client端做洞察,然後上報對應hotkey,server端檢測到後,將對應hotkey下發到對應服務端做本地快取,並且能保證本地快取和遠端快取的一致性。

在這裡我們們就不細談了,這篇文章的第三部分:JD開源hotkey原始碼解析裡面會帶領大家瞭解其整體原理。

3 JD開源hotkey—自動探測熱key、分散式一致性快取解決方案

3.1 解決痛點

從上面可知,熱點key問題在併發量比較高的系統中(特別是做秒殺活動)出現的頻率會比較高,對系統帶來的危害也很大。

那麼針對此,hotkey誕生的目的是什麼?需要解決的痛點是什麼?以及它的實現原理。

在這裡引用專案上的一段話來概述:對任意突發性的無法預先感知的熱點資料,包括並不限於熱點資料(如突發大量請求同一個商品)、熱使用者(如惡意爬蟲刷子)、熱介面(突發海量請求同一個介面)等,進行毫秒級精準探測到。然後對這些熱資料、熱使用者等,推送到所有服務端JVM記憶體中,以大幅減輕對後端資料儲存層的衝擊,並可以由使用者決定如何分配、使用這些熱key(譬如對熱商品做本地快取、對熱使用者進行拒絕訪問、對熱介面進行熔斷或返回預設值)。這些熱資料在整個服務端叢集內保持一致性,並且業務隔離。

核心功能:熱資料探測並推送至叢集各個伺服器

3.2 整合方式

整合方式在這裡就不詳述了,感興趣的同學可以自行搜尋。

3.3 原始碼解析

3.3.1 架構簡介

1.全景圖一覽


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


流程介紹:

  • 客戶端透過引用hotkey的client包,在啟動的時候上報自己的資訊給worker,同時和worker之間建立長連線。定時拉取配置中心上面的規則資訊和worker叢集資訊。

  • 客戶端呼叫hotkey的ishot()的方法來首先匹配規則,然後統計是不是熱key。

  • 透過定時任務把熱key資料上傳到worker節點。

  • worker叢集在收取到所有關於這個key的資料以後(因為透過hash來決定key 上傳到哪個worker的,所以同一個key只會在同一個worker節點上),在和定義的規則進行匹配後判斷是不是熱key,如果是則推送給客戶端,完成本地快取。

2.角色構成

這裡直接借用作者的描述:

1)etcd叢集etcd作為一個高效能的配置中心,可以以極小的資源佔用,提供高效的監聽訂閱服務。主要用於存放規則配置,各worker的ip地址,以及探測出的熱key、手工新增的熱key等。

2)client端jar包就是在服務中新增的引用jar,引入後,就可以以便捷的方式去判斷某key是否熱key。同時,該jar完成了key上報、監聽etcd裡的rule變化、worker資訊變化、熱key變化,對熱key進行本地caffeine快取等。

3) worker端叢集worker端是一個獨立部署的Java程式,啟動後會連線etcd,並定期上報自己的ip資訊,供client端獲取地址並進行長連線。之後,主要就是對各個client發來的待測key進行累加計算,當達到etcd裡設定的rule閾值後,將熱key推送到各個client。

4) dashboard控制檯控制檯是一個帶視覺化介面的Java程式,也是連線到etcd,之後在控制檯設定各個APP的key規則,譬如2秒20次算熱。然後當worker探測出來熱key後,會將key發往etcd,dashboard也會監聽熱key資訊,進行入庫儲存記錄。同時,dashboard也可以手工新增、刪除熱key,供各個client端監聽。

3.hotkey工程結構


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


3.3.2 client端

主要從下面三個方面來解析原始碼:


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


1.客戶端啟動器

1)啟動方式


@PostConstruct
public void init() {
    ClientStarter.Builder builder = new ClientStarter.Builder();
    ClientStarter starter = builder.setAppName(appName).setEtcdServer(etcd).build();
    starter.startPipeline();
}


appName:是這個應用的名稱,一般為${spring.application.name}的值,後續所有的配置都以此為開頭

etcd:是etcd叢集的地址,用逗號分隔,配置中心。

還可以看到ClientStarter實現了建造者模式,使程式碼更為簡介。

2)核心入口com.jd.platform.hotkey.client.ClientStarter#startPipeline


/**
 * 啟動監聽etcd
 */
public void startPipeline() {
    JdLogger.info(getClass(), "etcdServer:" + etcdServer);
    //設定caffeine的最大容量
    Context.CAFFEINE_SIZE = caffeineSize;

    //設定etcd地址
    EtcdConfigFactory.buildConfigCenter(etcdServer);
    //開始定時推送
    PushSchedulerStarter.startPusher(pushPeriod);
    PushSchedulerStarter.startCountPusher(10);
    //開啟worker重連器
    WorkerRetryConnector.retryConnectWorkers();

    registEventBus();

    EtcdStarter starter = new EtcdStarter();
    //與etcd相關的監聽都開啟
    starter.start();
}


該方法主要有五個功能:


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


設定本地快取(caffeine)的最大值,並建立etcd例項


//設定caffeine的最大容量
Context.CAFFEINE_SIZE = caffeineSize;

//設定etcd地址
EtcdConfigFactory.buildConfigCenter(etcdServer);


caffeineSize是本地快取的最大值,在啟動的時候可以設定,不設定預設為200000。etcdServer是上面說的etcd叢集地址。

Context可以理解為一個配置類,裡面就包含兩個欄位:


public class Context {
    public static String APP_NAME;

    public static int CAFFEINE_SIZE;
}


EtcdConfigFactory是ectd配置中心的工廠類


public class EtcdConfigFactory {
    private static IConfigCenter configCenter;

    private EtcdConfigFactory() {}

    public static IConfigCenter configCenter() {
        return configCenter;
    }

    public static void buildConfigCenter(String etcdServer) {
        //連線多個時,逗號分隔
        configCenter = JdEtcdBuilder.build(etcdServer);
    }
}


透過其configCenter()方法獲取建立etcd例項物件,IConfigCenter介面封裝了etcd例項物件的行為(包括基本的crud、監控、續約等)


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


建立並啟動定時任務:PushSchedulerStarter


//開始定時推送
PushSchedulerStarter.startPusher(pushPeriod);//每0.5秒推送一次待測key
PushSchedulerStarter.startCountPusher(10);//每10秒推送一次數量統計,不可配置


pushPeriod是推送的間隔時間,可以再啟動的時候設定,最小為0.05s,推送越快,探測的越密集,會越快探測出來,但對client資源消耗相應增大

PushSchedulerStarter類


/**
     * 每0.5秒推送一次待測key
     */
    public static void startPusher(Long period) {
        if (period == null || period <= 0) {
            period = 500L;
        }
        @SuppressWarnings("PMD.ThreadPoolCreationRule")
        ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("hotkey-pusher-service-executor", true));
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            //熱key的收集器
            IKeyCollector<HotKeyModel, HotKeyModel> collectHK = KeyHandlerFactory.getCollector();
            //這裡相當於每0.5秒,透過netty來給worker來推送收集到的熱key的資訊,主要是一些熱key的後設資料資訊(熱key來源的app和key的型別和是否是刪除事件,還有該熱key的上報次數)
            //這裡面還有就是該熱key在每次上報的時候都會生成一個全域性的唯一id,還有該熱key每次上報的建立時間是在netty傳送的時候來生成,同一批次的熱key時間是相同的
            List<HotKeyModel> hotKeyModels = collectHK.lockAndGetResult();
            if(CollectionUtil.isNotEmpty(hotKeyModels)){
                //積攢了半秒的key集合,按照hash分發到不同的worker
                KeyHandlerFactory.getPusher().send(Context.APP_NAME, hotKeyModels);
                collectHK.finishOnce();
            }

        },0, period, TimeUnit.MILLISECONDS);
    }
    /**
     * 每10秒推送一次數量統計
     */
    public static void startCountPusher(Integer period) {
        if (period == null || period <= 0) {
            period = 10;
        }
        @SuppressWarnings("PMD.ThreadPoolCreationRule")
        ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("hotkey-count-pusher-service-executor", true));
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            IKeyCollector<KeyHotModel, KeyCountModel> collectHK = KeyHandlerFactory.getCounter();
            List<KeyCountModel> keyCountModels = collectHK.lockAndGetResult();
            if(CollectionUtil.isNotEmpty(keyCountModels)){
                //積攢了10秒的數量,按照hash分發到不同的worker
                KeyHandlerFactory.getPusher().sendCount(Context.APP_NAME, keyCountModels);
                collectHK.finishOnce();
            }
        },0, period, TimeUnit.SECONDS);
    }


從上面兩個方法可知,都是透過定時執行緒池來實現定時任務的,都是守護執行緒。

我們們重點關注一下KeyHandlerFactory類,它是client端設計的一個比較巧妙的地方,從類名上直譯為key處理工廠。具體的例項物件是DefaultKeyHandler:


public class DefaultKeyHandler {
    //推送HotKeyMsg訊息到Netty的推送者
    private IKeyPusher iKeyPusher = new NettyKeyPusher();
    //待測key的收集器,這裡麵包含兩個map,key主要是熱key的名字,value主要是熱key的後設資料資訊(比如:熱key來源的app和key的型別和是否是刪除事件)
    private IKeyCollector<HotKeyModel, HotKeyModel> iKeyCollector = new TurnKeyCollector();
    //數量收集器,這裡麵包含兩個map,這裡面key是相應的規則,HitCount裡面是這個規則的總訪問次數和熱後訪問次數
    private IKeyCollector<KeyHotModel, KeyCountModel> iKeyCounter = new TurnCountCollector();

    public IKeyPusher keyPusher() {
        return iKeyPusher;
    }
    public IKeyCollector<HotKeyModel, HotKeyModel> keyCollector() {
        return iKeyCollector;
    }
    public IKeyCollector<KeyHotModel, KeyCountModel> keyCounter() {
        return iKeyCounter;
    }
}


這裡面有三個成員物件,分別是封裝推送訊息到netty的NettyKeyPusher、待測key收集器TurnKeyCollector、數量收集器TurnCountCollector,其中後兩者都實現了介面IKeyCollector,能對hotkey的處理起到有效的聚合,充分體現了程式碼的高內聚。先來看看封裝推送訊息到netty的NettyKeyPusher:


/**
 * 將msg推送到netty的pusher
 * @author wuweifeng wrote on 2020-01-06
 * @version 1.0
 */
public class NettyKeyPusher implements IKeyPusher {
    @Override
    public void send(String appName, List<HotKeyModel> list) {
        //積攢了半秒的key集合,按照hash分發到不同的worker
        long now = System.currentTimeMillis();

        Map<Channel, List<HotKeyModel>> map = new HashMap<>();
        for(HotKeyModel model : list) {
            model.setCreateTime(now);
            Channel channel = WorkerInfoHolder.chooseChannel(model.getKey());
            if (channel == null) {
                continue;
            }
            List<HotKeyModel> newList = map.computeIfAbsent(channel, k -> new ArrayList<>());
            newList.add(model);
        }

        for (Channel channel : map.keySet()) {
            try {
                List<HotKeyModel> batch = map.get(channel);
                HotKeyMsg hotKeyMsg = new HotKeyMsg(MessageType.REQUEST_NEW_KEY, Context.APP_NAME);
                hotKeyMsg.setHotKeyModels(batch);
                channel.writeAndFlush(hotKeyMsg).sync();
            } catch (Exception e) {
                try {
                    InetSocketAddress insocket = (InetSocketAddress) channel.remoteAddress();
                    JdLogger.error(getClass(),"flush error " + insocket.getAddress().getHostAddress());
                } catch (Exception ex) {
                    JdLogger.error(getClass(),"flush error");
                }
            }
        }
    }
    @Override
    public void sendCount(String appName, List<KeyCountModel> list) {
        //積攢了10秒的數量,按照hash分發到不同的worker
        long now = System.currentTimeMillis();
        Map<Channel, List<KeyCountModel>> map = new HashMap<>();
        for(KeyCountModel model : list) {
            model.setCreateTime(now);
            Channel channel = WorkerInfoHolder.chooseChannel(model.getRuleKey());
            if (channel == null) {
                continue;
            }
            List<KeyCountModel> newList = map.computeIfAbsent(channel, k -> new ArrayList<>());
            newList.add(model);
        }
        for (Channel channel : map.keySet()) {
            try {
                List<KeyCountModel> batch = map.get(channel);
                HotKeyMsg hotKeyMsg = new HotKeyMsg(MessageType.REQUEST_HIT_COUNT, Context.APP_NAME);
                hotKeyMsg.setKeyCountModels(batch);
                channel.writeAndFlush(hotKeyMsg).sync();
            } catch (Exception e) {
                try {
                    InetSocketAddress insocket = (InetSocketAddress) channel.remoteAddress();
                    JdLogger.error(getClass(),"flush error " + insocket.getAddress().getHostAddress());
                } catch (Exception ex) {
                    JdLogger.error(getClass(),"flush error");
                }
            }
        }
    }
}


send(String appName, List list)主要是將TurnKeyCollector收集的待測key透過netty推送給worker,HotKeyModel物件主要是一些熱key的後設資料資訊(熱key來源的app和key的型別和是否是刪除事件,還有該熱key的上報次數)

sendCount(String appName, List list)主要是將TurnCountCollector收集的規則所對應的key透過netty推送給worker,KeyCountModel物件主要是一些key所對應的規則資訊以及訪問次數等

WorkerInfoHolder.chooseChannel(model.getRuleKey())根據hash演算法獲取key對應的伺服器,分發到對應伺服器相應的Channel 連線,所以服務端可以水平無限擴容,毫無壓力問題。

再來分析一下key收集器:TurnKeyCollector與TurnCountCollector:實現IKeyCollector介面:


/**
 * 對hotkey進行聚合
 * @author wuweifeng wrote on 2020-01-06
 * @version 1.0
 */
public interface IKeyCollector<T, V> {
    /**
     * 鎖定後的返回值
     */
    List<V> lockAndGetResult();
    /**
     * 輸入的引數
     */
    void collect(T t);

    void finishOnce();
}


lockAndGetResult()主要是獲取返回collect方法收集的資訊,並將本地暫存的資訊清空,方便下個統計週期積攢資料。

collect(T t)顧名思義他是收集api呼叫的時候,將收集的到key資訊放到本地儲存。

finishOnce()該方法目前實現都是空,無需關注。

待測key收集器:TurnKeyCollector


public class TurnKeyCollector implements IKeyCollector<HotKeyModel, HotKeyModel> {
    //這map裡面的key主要是熱key的名字,value主要是熱key的後設資料資訊(比如:熱key來源的app和key的型別和是否是刪除事件)
    private ConcurrentHashMap<String, HotKeyModel> map0 = new ConcurrentHashMap<>();
    private ConcurrentHashMap<String, HotKeyModel> map1 = new ConcurrentHashMap<>();
    private AtomicLong atomicLong = new AtomicLong(0);

    @Override
    public List<HotKeyModel> lockAndGetResult() {
        //自增後,對應的map就會停止被寫入,等待被讀取
        atomicLong.addAndGet(1);
        List<HotKeyModel> list;
        //可以觀察這裡與collect方法裡面的相同位置,會發現一個是操作map0一個是操作map1,這樣保證在讀map的時候,不會阻塞寫map,
        //兩個map同時提供輪流提供讀寫能力,設計的很巧妙,值得學習
        if (atomicLong.get() % 2 == 0) {
            list = get(map1);
            map1.clear();
        } else {
            list = get(map0);
            map0.clear();
        }
        return list;
    }
    private List<HotKeyModel> get(ConcurrentHashMap<String, HotKeyModel> map) {
        return CollectionUtil.list(false, map.values());
    }
    @Override
    public void collect(HotKeyModel hotKeyModel) {
        String key = hotKeyModel.getKey();
        if (StrUtil.isEmpty(key)) {
            return;
        }
        if (atomicLong.get() % 2 == 0) {
            //不存在時返回null並將key-value放入,已有相同key時,返回該key對應的value,並且不覆蓋
            HotKeyModel model = map0.putIfAbsent(key, hotKeyModel);
            if (model != null) {
                //增加該hotMey上報的次數
                model.add(hotKeyModel.getCount());
            }
        } else {
            HotKeyModel model = map1.putIfAbsent(key, hotKeyModel);
            if (model != null) {
                model.add(hotKeyModel.getCount());
            }
        }
    }
    @Override
    public void finishOnce() {}
}


可以看到該類中有兩個ConcurrentHashMap和一個AtomicLong,透過對AtomicLong來自增,然後對2取模,來分別控制兩個map的讀寫能力,保證每個map都能做讀寫,並且同一個map不能同時讀寫,這樣可以避免併發集合讀寫不阻塞,這一塊無鎖化的設計還是非常巧妙的,極大的提高了收集的吞吐量。

key數量收集器:TurnCountCollector這裡的設計與TurnKeyCollector大同小異,我們們就不細談了。值得一提的是它裡面有個並行處理的機制,當收集的數量超過DATA_CONVERT_SWITCH_THRESHOLD=5000的閾值時,lockAndGetResult處理是使用java Stream並行流處理,提升處理的效率。

開啟worker重連器


//開啟worker重連器
WorkerRetryConnector.retryConnectWorkers();
public class WorkerRetryConnector {

    /**
     * 定時去重連沒連上的workers
     */
    public static void retryConnectWorkers() {
        @SuppressWarnings("PMD.ThreadPoolCreationRule")
        ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("worker-retry-connector-service-executor", true));
        //開啟拉取etcd的worker資訊,如果拉取失敗,則定時繼續拉取
        scheduledExecutorService.scheduleAtFixedRate(WorkerRetryConnector::reConnectWorkers, 30, 30, TimeUnit.SECONDS);
    }

    private static void reConnectWorkers() {
        List<String> nonList = WorkerInfoHolder.getNonConnectedWorkers();
        if (nonList.size() == 0) {
            return;
        }
        JdLogger.info(WorkerRetryConnector.class, "trying to reConnect to these workers :" + nonList);
        NettyClient.getInstance().connect(nonList);//這裡會觸發netty連線方法channelActive
    }
}


也是透過定時執行緒來執行,預設時間間隔是30s,不可設定。透過WorkerInfoHolder來控制client的worker連線資訊,連線資訊是個List,用的CopyOnWriteArrayList,畢竟是一個讀多寫少的場景,類似與後設資料資訊。


/**
 * 儲存worker的ip地址和Channel的對映關係,這是有序的。每次client傳送訊息時,都會根據該map的size進行hash
 * 如key-1就傳送到workerHolder的第1個Channel去,key-2就發到第2個Channel去
 */
private static final List<Server> WORKER_HOLDER = new CopyOnWriteArrayList<>();


註冊EventBus事件訂閱者


private void registEventBus() {
    //netty聯結器會關注WorkerInfoChangeEvent事件
    EventBusCenter.register(new WorkerChangeSubscriber());
    //熱key探測回撥關注熱key事件
    EventBusCenter.register(new ReceiveNewKeySubscribe());
    //Rule的變化的事件
    EventBusCenter.register(new KeyRuleHolder());
}


使用guava的EventBus事件訊息匯流排,利用釋出/訂閱者模式來對專案進行解耦。它可以利用很少的程式碼,來實現多元件間通訊。

基本原理圖如下:


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


監聽worker資訊變動:WorkerChangeSubscriber


/**
 * 監聽worker資訊變動
 */
@Subscribe
public void connectAll(WorkerInfoChangeEvent event) {
    List<String> addresses = event.getAddresses();
    if (addresses == null) {
        addresses = new ArrayList<>();
    }

    WorkerInfoHolder.mergeAndConnectNew(addresses);
}
/**
 * 當client與worker的連線斷開後,刪除
 */
@Subscribe
public void channelInactive(ChannelInactiveEvent inactiveEvent) {
    //獲取斷線的channel
    Channel channel = inactiveEvent.getChannel();
    InetSocketAddress socketAddress = (InetSocketAddress) channel.remoteAddress();
    String address = socketAddress.getHostName() + ":" + socketAddress.getPort();
    JdLogger.warn(getClass(), "this channel is inactive : " + socketAddress + " trying to remove this connection");

    WorkerInfoHolder.dealChannelInactive(address);
}



Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


監聽熱key回撥事件:ReceiveNewKeySubscribe


private ReceiveNewKeyListener receiveNewKeyListener = new DefaultNewKeyListener();

@Subscribe
public void newKeyComing(ReceiveNewKeyEvent event) {
    HotKeyModel hotKeyModel = event.getModel();
    if (hotKeyModel == null) {
        return;
    }
    //收到新key推送
    if (receiveNewKeyListener != null) {
        receiveNewKeyListener.newKey(hotKeyModel);
    }
}


該方法會收到新的熱key訂閱事件之後,會將其加入到KeyHandlerFactory的收集器裡面處理。

核心處理邏輯:DefaultNewKeyListener#newKey:


@Override
public void newKey(HotKeyModel hotKeyModel) {
    long now = System.currentTimeMillis();
    //如果key到達時已經過去1秒了,記錄一下。手工刪除key時,沒有CreateTime
    if (hotKeyModel.getCreateTime() != 0 && Math.abs(now - hotKeyModel.getCreateTime()) > 1000) {
        JdLogger.warn(getClass(), "the key comes too late : " + hotKeyModel.getKey() + " now " +
                +now + " keyCreateAt " + hotKeyModel.getCreateTime());
    }
    if (hotKeyModel.isRemove()) {
        //如果是刪除事件,就直接刪除
        deleteKey(hotKeyModel.getKey());
        return;
    }
    //已經是熱key了,又推過來同樣的熱key,做個日誌記錄,並重新整理一下
    if (JdHotKeyStore.isHot(hotKeyModel.getKey())) {
        JdLogger.warn(getClass(), "receive repeat hot key :" + hotKeyModel.getKey() + " at " + now);
    }
    addKey(hotKeyModel.getKey());
}
private void deleteKey(String key) {
        CacheFactory.getNonNullCache(key).delete(key);
}
private void addKey(String key) {
  ValueModel valueModel = ValueModel.defaultValue(key);
  if (valueModel == null) {
      //不符合任何規則
      deleteKey(key);
      return;
  }
  //如果原來該key已經存在了,那麼value就被重置,過期時間也會被重置。如果原來不存在,就新增的熱key
   JdHotKeyStore.setValueDirectly(key, valueModel);
}


  1. 如果該HotKeyModel裡面是刪除事件,則獲取RULE_CACHE_MAP裡面該key超時時間對應的caffeine,然後從中刪除該key快取,然後返回(這裡相當於刪除了本地快取)。

  2. 如果不是刪除事件,則在RULE_CACHE_MAP對應的caffeine快取中新增該key的快取。

  3. 這裡有個注意點,如果不為刪除事件,呼叫addKey()方法在caffeine增加快取的時候,value是一個魔術值0x12fcf76,這個值只代表加了這個快取,但是這個快取在查詢的時候相當於為null。

監聽Rule的變化事件:KeyRuleHolder


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


可以看到裡面有兩個成員屬性:RULE_CACHE_MAP,KEY_RULES


/**
 * 儲存超時時間和caffeine的對映,key是超時時間,value是caffeine[(String,Object)]
 */
private static final ConcurrentHashMap<Integer, LocalCache> RULE_CACHE_MAP = new ConcurrentHashMap<>();
/**
 * 這裡KEY_RULES是儲存etcd裡面該appName所對應的所有rule
 */
private static final List<KeyRule> KEY_RULES = new ArrayList<>();


ConcurrentHashMap RULE_CACHE_MAP:

  • 儲存超時時間和caffeine的對映,key是超時時間,value是caffeine[(String,Object)]。

  • 巧妙的設計:這裡將key的過期時間作為分桶策略,這樣同一個過期時間的key就會在一個桶(caffeine)裡面,這裡面每一個caffeine都是client的本地快取,也就是說hotKey的本地快取的KV實際上是儲存在這裡面的。

List KEY_RULES:

  • 這裡KEY_RULES是儲存etcd裡面該appName所對應的所有rule。

具體監聽KeyRuleInfoChangeEvent事件方法:


@Subscribe
public void ruleChange(KeyRuleInfoChangeEvent event) {
    JdLogger.info(getClass(), "new rules info is :" + event.getKeyRules());
    List<KeyRule> ruleList = event.getKeyRules();
    if (ruleList == null) {
        return;
    }

    putRules(ruleList);
}


核心處理邏輯:KeyRuleHolder#putRules:


/**
 * 所有的規則,如果規則的超時時間變化了,會重建caffeine
 */
public static void putRules(List<KeyRule> keyRules) {
    synchronized (KEY_RULES) {
        //如果規則為空,清空規則表
        if (CollectionUtil.isEmpty(keyRules)) {
            KEY_RULES.clear();
            RULE_CACHE_MAP.clear();
            return;
        }
        KEY_RULES.clear();
        KEY_RULES.addAll(keyRules);
        Set<Integer> durationSet = keyRules.stream().map(KeyRule::getDuration).collect(Collectors.toSet());
        for (Integer duration : RULE_CACHE_MAP.keySet()) {
            //先清除掉那些在RULE_CACHE_MAP裡存的,但是rule裡已沒有的
            if (!durationSet.contains(duration)) {
                RULE_CACHE_MAP.remove(duration);
            }
        }
        //遍歷所有的規則
        for (KeyRule keyRule : keyRules) {
            int duration = keyRule.getDuration();
            //這裡如果RULE_CACHE_MAP裡面沒有超時時間為duration的value,則新建一個放入到RULE_CACHE_MAP裡面
            //比如RULE_CACHE_MAP本來就是空的,則在這裡來構建RULE_CACHE_MAP的對映關係
            //TODO 如果keyRules裡面包含相同duration的keyRule,則也只會建一個key為duration,value為caffeine,其中caffeine是(string,object)
            if (RULE_CACHE_MAP.get(duration) == null) {
                LocalCache cache = CacheFactory.build(duration);
                RULE_CACHE_MAP.put(duration, cache);
            }
        }
    }
}


  • 使用synchronized關鍵字來保證執行緒安全;

  • 如果規則為空,清空規則表(RULE_CACHE_MAP、KEY_RULES);

  • 使用傳遞進來的keyRules來覆蓋KEY_RULES;

  • 清除掉RULE_CACHE_MAP裡面在keyRules沒有的對映關係;

  • 遍歷所有的keyRules,如果RULE_CACHE_MAP裡面沒有相關的超時時間key,則在裡面賦值;

啟動EtcdStarter(etcd連線管理器)


EtcdStarter starter = new EtcdStarter();
//與etcd相關的監聽都開啟
starter.start();

public void start() {
fetchWorkerInfo();
fetchRule();
startWatchRule();
//監聽熱key事件,只監聽手工新增、刪除的key
startWatchHotKey();
}


fetchWorkerInfo()

從etcd裡面拉取worker叢集地址資訊allAddress,並更新WorkerInfoHolder裡面的WORKER_HOLDER


/**
 * 每隔30秒拉取worker資訊
 */
private void fetchWorkerInfo() {
    ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
    //開啟拉取etcd的worker資訊,如果拉取失敗,則定時繼續拉取
    scheduledExecutorService.scheduleAtFixedRate(() -> {
        JdLogger.info(getClass(), "trying to connect to etcd and fetch worker info");
        fetch();

    }, 0, 30, TimeUnit.SECONDS);
}


  • 使用定時執行緒池來執行,單執行緒。

  • 定時從etcd裡面獲取,地址/jd/workers/+$appName或default,時間間隔不可設定,預設30秒,這裡面儲存的是worker地址的ip+port。

  • 釋出WorkerInfoChangeEvent事件。

  • 備註:地址有$appName或default,在worker裡面配置,如果把worker放到某個appName下,則該worker只會參與該app的計算。

fetchRule()

定時執行緒來執行,單執行緒,時間間隔不可設定,預設是5秒,當拉取規則配置和手動配置的hotKey成功後,該執行緒被終止(也就是說只會成功執行一次),執行失敗繼續執行


private void fetchRule() {
    ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
    //開啟拉取etcd的worker資訊,如果拉取失敗,則定時繼續拉取
    scheduledExecutorService.scheduleAtFixedRate(() -> {
        JdLogger.info(getClass(), "trying to connect to etcd and fetch rule info");
        boolean success = fetchRuleFromEtcd();
        if (success) {
            //拉取已存在的熱key
            fetchExistHotKey();
            //這裡如果拉取規則和拉取手動配置的hotKey成功之後,則該定時執行執行緒停止
            scheduledExecutorService.shutdown();
        }
    }, 0, 5, TimeUnit.SECONDS);
}


fetchRuleFromEtcd()

  • 從etcd裡面獲取該appName配置的rule規則,地址/jd/rules/+$appName。

  • 如果查出來規則rules為空,會透過釋出KeyRuleInfoChangeEvent事件來清空本地的rule配置快取和所有的規則key快取。

  • 釋出KeyRuleInfoChangeEvent事件。

fetchExistHotKey()

  • 從etcd裡面獲取該appName手動配置的熱key,地址/jd/hotkeys/+$appName。

  • 釋出ReceiveNewKeyEvent事件,並且內容HotKeyModel不是刪除事件。

startWatchRule()


/**
 * 非同步監聽rule規則變化
 */
private void startWatchRule() {
    ExecutorService executorService = Executors.newSingleThreadExecutor();
    executorService.submit(() -> {
        JdLogger.info(getClass(), "--- begin watch rule change ----");
        try {
            IConfigCenter configCenter = EtcdConfigFactory.configCenter();
            KvClient.WatchIterator watchIterator = configCenter.watch(ConfigConstant.rulePath + Context.APP_NAME);
            //如果有新事件,即rule的變更,就重新拉取所有的資訊
            while (watchIterator.hasNext()) {
                //這句必須寫,next會讓他卡住,除非真的有新rule變更
                WatchUpdate watchUpdate = watchIterator.next();
                List<Event> eventList = watchUpdate.getEvents();
                JdLogger.info(getClass(), "rules info changed. begin to fetch new infos. rule change is " + eventList);

                //全量拉取rule資訊
                fetchRuleFromEtcd();
            }
        } catch (Exception e) {
            JdLogger.error(getClass(), "watch err");
        }
    });
}


  • 非同步監聽rule規則變化,使用etcd監聽地址為/jd/rules/+$appName的節點變化。

  • 使用執行緒池,單執行緒,非同步監聽rule規則變化,如果有事件變化,則呼叫fetchRuleFromEtcd()方法。

startWatchHotKey()非同步開始監聽熱key變化資訊,使用etcd監聽地址字首為/jd/hotkeys/+$appName


/**
 * 非同步開始監聽熱key變化資訊,該目錄裡只有手工新增的key資訊
 */
private void startWatchHotKey() {
    ExecutorService executorService = Executors.newSingleThreadExecutor();
    executorService.submit(() -> {
        JdLogger.info(getClass(), "--- begin watch hotKey change ----");
        IConfigCenter configCenter = EtcdConfigFactory.configCenter();
        try {
            KvClient.WatchIterator watchIterator = configCenter.watchPrefix(ConfigConstant.hotKeyPath + Context.APP_NAME);
            //如果有新事件,即新key產生或刪除
            while (watchIterator.hasNext()) {
                WatchUpdate watchUpdate = watchIterator.next();

                List<Event> eventList = watchUpdate.getEvents();
                KeyValue keyValue = eventList.get(0).getKv();
                Event.EventType eventType = eventList.get(0).getType();
                try {
                    //從這個地方可以看出,etcd給的返回是節點的全路徑,而我們需要的key要去掉字首
                    String key = keyValue.getKey().toStringUtf8().replace(ConfigConstant.hotKeyPath + Context.APP_NAME + "/", "");
                    //如果是刪除key,就立刻刪除
                    if (Event.EventType.DELETE == eventType) {
                        HotKeyModel model = new HotKeyModel();
                        model.setRemove(true);
                        model.setKey(key);
                        EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
                    } else {
                        HotKeyModel model = new HotKeyModel();
                        model.setRemove(false);
                        String value = keyValue.getValue().toStringUtf8();
                        //新增熱key
                        JdLogger.info(getClass(), "etcd receive new key : " + key + " --value:" + value);
                        //如果這是一個刪除指令,就什麼也不幹
                        //TODO 這裡有個疑問,監聽到worker自動探測發出的惰性刪除指令,這裡之間跳過了,但是本地快取沒有更新吧?
                        //TODO 所以我猜測在客戶端使用判斷快取是否存在的api裡面,應該會判斷相關快取的value值是否為"#[DELETE]#"刪除標記
                        //解疑:這裡確實只監聽手工配置的hotKey,etcd的/jd/hotkeys/+$appName該地址只是手動配置hotKey,worker自動探測的hotKey是直接透過netty通道來告知client的
                        if (Constant.DEFAULT_DELETE_VALUE.equals(value)) {
                            continue;
                        }
                        //手工建立的value是時間戳
                        model.setCreateTime(Long.valueOf(keyValue.getValue().toStringUtf8()));
                        model.setKey(key);
                        EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
                    }
                } catch (Exception e) {
                    JdLogger.error(getClass(), "new key err :" + keyValue);
                }

            }
        } catch (Exception e) {
            JdLogger.error(getClass(), "watch err");
        }
    });

}


  • 使用執行緒池,單執行緒,非同步監聽熱key變化

  • 使用etcd監聽字首地址的當前節點以及子節點的所有變化值

  • 刪除節點動作

  • 釋出ReceiveNewKeyEvent事件,並且內容HotKeyModel是刪除事件

  • 新增or更新節點動作

  • 事件變化的value值為刪除標記#[DELETE]#

  • 如果是刪除標記的話,代表是worker自動探測或者client需要刪除的指令。

  • 如果是刪除標記則什麼也不做,直接跳過(這裡從HotKeyPusher#push方法可以看到,做刪除事件的操作時候,他會給/jd/hotkeys/+$appName的節點裡面增加一個值為刪除標記的節點,然後再刪除相同路徑的節點,這樣就可以觸發上面的刪除節點事件,所以這裡判斷如果是刪除標記直接跳過)。

  • 不為刪除標記

  • 釋出ReceiveNewKeyEvent事件,事件內容HotKeyModel裡面的createTime是kv對應的時間戳

疑問: 這裡程式碼註釋裡面說只監聽手工新增或者刪除的hotKey,難道說/jd/hotkeys/+$appName地址只是手工配置的地址嗎?

解疑: 這裡確實只監聽手工配置的hotKey,etcd的/jd/hotkeys/+$appName該地址只是手動配置hotKey,worker自動探測的hotKey是直接透過netty通道來告知client的

2.API解析

1)流程圖示 查詢流程


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


刪除流程:


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


從上面的流程圖中,大家應該知道該熱點key在程式碼中是如何扭轉的,這裡再給大家講解一下核心API的原始碼解析,限於篇幅的原因,我們們不一個個貼相關原始碼了,只是單純的告訴你它的內部邏輯是怎麼樣的。

2)核心類:JdHotKeyStore


Redis 資料傾斜與 JD 開源 hotkey 原始碼分析揭秘


JdHotKeyStore是封裝client呼叫的api核心類,包含上面10個公共方法,我們們重點解析其中6個重要方法:

isHotKey(String key)判斷是否在規則內,如果不在,返回false判斷是否是熱key,如果不是或者是且過期時間在2s內,則給TurnKeyCollector#collect收集最後給TurnCountCollector#collect做統計收集

get(String key)從本地caffeine取值如果取到的value是個魔術值,只代表加入到caffeine快取裡面了,查詢的話為null

smartSet(String key, Object value)判斷是否是熱key,這裡不管它在不在規則內,如果是熱key,則給value賦值,如果不為熱key什麼也不做

forceSet(String key, Object value)強制給value賦值如果該key不在規則配置內,則傳遞的value不生效,本地快取的賦值value會被變為null

getValue(String key, KeyType keyType)獲取value,如果value不存在則呼叫HotKeyPusher#push方法發往netty如果沒有為該key配置規則,就不用上報key,直接返回null如果取到的value是個魔術值,只代表加入到caffeine快取裡面了,查詢的話為null

remove(String key)刪除某key(本地的caffeine快取),會通知整個叢集刪除(透過etcd來通知叢集刪除)

3)client上傳熱key入口呼叫類:HotKeyPusher核心方法:


public static void push(String key, KeyType keyType, int count, boolean remove) {
    if (count <= 0) {
        count = 1;
    }
    if (keyType == null) {
        keyType = KeyType.REDIS_KEY;
    }
    if (key == null) {
        return;
    }
    //這裡之所以用LongAdder是為了保證多執行緒計數的執行緒安全性,雖然這裡是在方法內呼叫的,但是在TurnKeyCollector的兩個map裡面,
    //儲存了HotKeyModel的例項物件,這樣在多個執行緒同時修改count的計數屬性時,會存線上程安全計數不準確問題
    LongAdder adderCnt = new LongAdder();
    adderCnt.add(count);

    HotKeyModel hotKeyModel = new HotKeyModel();
    hotKeyModel.setAppName(Context.APP_NAME);
    hotKeyModel.setKeyType(keyType);
    hotKeyModel.setCount(adderCnt);
    hotKeyModel.setRemove(remove);
    hotKeyModel.setKey(key);


    if (remove) {
        //如果是刪除key,就直接發到etcd去,不用做聚合。但是有點問題現在,這個刪除只能刪手工新增的key,不能刪worker探測出來的
        //因為各個client都在監聽手工新增的那個path,沒監聽自動探測的path。所以如果手工的那個path下,沒有該key,那麼是刪除不了的。
        //刪不了,就達不到叢集監聽刪除事件的效果,怎麼辦呢?可以透過新增的方式,新增一個熱key,然後刪除它
        //TODO 這裡為啥不直接刪除該節點,難道worker自動探測處理的hotKey不會往該節點增加新增事件嗎?
        //釋疑:worker根據探測配置的規則,當判斷出某個key為hotKey後,確實不會往keyPath裡面加入節點,他只是單純的往本地快取裡面加入一個空值,代表是熱點key
        EtcdConfigFactory.configCenter().putAndGrant(HotKeyPathTool.keyPath(hotKeyModel), Constant.DEFAULT_DELETE_VALUE, 1);
        EtcdConfigFactory.configCenter().delete(HotKeyPathTool.keyPath(hotKeyModel));//TODO 這裡很巧妙待補充描述
        //也刪worker探測的目錄
        EtcdConfigFactory.configCenter().delete(HotKeyPathTool.keyRecordPath(hotKeyModel));
    } else {
        //如果key是規則內的要被探測的key,就積累等待傳送
        if (KeyRuleHolder.isKeyInRule(key)) {
            //積攢起來,等待每半秒傳送一次
            KeyHandlerFactory.getCollector().collect(hotKeyModel);
        }
    }
}


從上面的原始碼中可知:

  • 這裡之所以用LongAdder是為了保證多執行緒計數的執行緒安全性,雖然這裡是在方法內呼叫的,但是在TurnKeyCollector的兩個map裡面,儲存了HotKeyModel的例項物件,這樣在多個執行緒同時修改count的計數屬性時,會存線上程安全計數不準確問題。

  • 如果是remove刪除型別,在刪除手動配置的熱key配置路徑的同時,還會刪除dashboard展示熱key的配置路徑。

  • 只有在規則配置的key,才會被積攢探測傳送到worker內進行計算。

3.通訊機制(與worker互動)

1)NettyClient:netty聯結器


public class NettyClient {
    private static final NettyClient nettyClient = new NettyClient();

    private Bootstrap bootstrap;

    public static NettyClient getInstance() {
        return nettyClient;
    }

    private NettyClient() {
        if (bootstrap == null) {
            bootstrap = initBootstrap();
        }
    }

    private Bootstrap initBootstrap() {
        //少執行緒
        EventLoopGroup group = new NioEventLoopGroup(2);

        Bootstrap bootstrap = new Bootstrap();
        NettyClientHandler nettyClientHandler = new NettyClientHandler();
        bootstrap.group(group).channel(NioSocketChannel.class)
                .option(ChannelOption.SO_KEEPALIVE, true)
                .option(ChannelOption.TCP_NODELAY, true)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ByteBuf delimiter = Unpooled.copiedBuffer(Constant.DELIMITER.getBytes());
                        ch.pipeline()
                                .addLast(new DelimiterBasedFrameDecoder(Constant.MAX_LENGTH, delimiter))//這裡就是定義TCP多個包之間的分隔符,為了更好的做拆包
                                .addLast(new MsgDecoder())
                                .addLast(new MsgEncoder())
                                //30秒沒訊息時,就發心跳包過去
                                .addLast(new IdleStateHandler(0, 0, 30))
                                .addLast(nettyClientHandler);
                    }
                });
        return bootstrap;
    }
}


  • 使用Reactor執行緒模型,只有2個工作執行緒,沒有單獨設定主執行緒

  • 長連線,開啟TCP_NODELAY

  • netty的分隔符”$( )$”,類似TCP報文分段的標準,方便拆包

  • Protobuf序列化與反序列化

  • 30s沒有訊息發給對端的時候,傳送一個心跳包判活

  • 工作執行緒處理器NettyClientHandler

JDhotkey的tcp協議設計就是收發字串,每個tcp訊息包使用特殊字元$( )$來分割優點:這樣實現非常簡單。

獲得訊息包後進行json或者protobuf反序列化。

缺點:是需要,從位元組流-》反序列化成字串-》反序列化成訊息物件,兩層序列化損耗了一部分效能。

protobuf還好序列化很快,但是json序列化的速度只有幾十萬每秒,會損耗一部分效能。

2)NettyClientHandler:工作執行緒處理器


@ChannelHandler.Sharable
public class NettyClientHandler extends SimpleChannelInboundHandler<HotKeyMsg> {
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent idleStateEvent = (IdleStateEvent) evt;
            //這裡表示如果讀寫都掛了
            if (idleStateEvent.state() == IdleState.ALL_IDLE) {
                //向服務端傳送訊息
                ctx.writeAndFlush(new HotKeyMsg(MessageType.PING, Context.APP_NAME));
            }
        }

        super.userEventTriggered(ctx, evt);
    }
    //在Channel註冊EventLoop、繫結SocketAddress和連線ChannelFuture的時候都有可能會觸發ChannelInboundHandler的channelActive方法的呼叫
    //類似TCP三次握手成功之後觸發
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        JdLogger.info(getClass(), "channelActive:" + ctx.name());
        ctx.writeAndFlush(new HotKeyMsg(MessageType.APP_NAME, Context.APP_NAME));
    }
    //類似TCP四次揮手之後,等待2MSL時間之後觸發(大概180s),比如channel通道關閉會觸發(channel.close())

    //客戶端channel主動關閉連線時,會向服務端傳送一個寫請求,然後服務端channel所在的selector會監聽到一個OP_READ事件,然後
    //執行資料讀取操作,而讀取時發現客戶端channel已經關閉了,則讀取資料位元組個數返回-1,然後執行close操作,關閉該channel對應的底層socket,
    //並在pipeline中,從head開始,往下將InboundHandler,並觸發handler的channelInactive和channelUnregistered方法的執行,以及移除pipeline中的handlers一系列操作。
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        super.channelInactive(ctx);
        //斷線了,可能只是client和server斷了,但都和etcd沒斷。也可能是client自己斷網了,也可能是server斷了
        //釋出斷線事件。後續10秒後進行重連,根據etcd裡的worker資訊來決定是否重連,如果etcd裡沒了,就不重連。如果etcd裡有,就重連
        notifyWorkerChange(ctx.channel());
    }
    private void notifyWorkerChange(Channel channel) {
        EventBusCenter.getInstance().post(new ChannelInactiveEvent(channel));
    }
    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, HotKeyMsg msg) {
        if (MessageType.PONG == msg.getMessageType()) {
            JdLogger.info(getClass(), "heart beat");
            return;
        }
        if (MessageType.RESPONSE_NEW_KEY == msg.getMessageType()) {
            JdLogger.info(getClass(), "receive new key : " + msg);
            if (CollectionUtil.isEmpty(msg.getHotKeyModels())) {
                return;
            }
            for (HotKeyModel model : msg.getHotKeyModels()) {
                EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
            }
        }
    }
}


userEventTriggered

  • 收到對端發來的心跳包,返回new HotKeyMsg(MessageType.PING, Context.APP_NAME)

channelActive

  • 在Channel註冊EventLoop、繫結SocketAddress和連線ChannelFuture的時候都有可能會觸發ChannelInboundHandler的channelActive方法的呼叫

  • 類似TCP三次握手成功之後觸發,給對端傳送new HotKeyMsg(MessageType.APP_NAME, Context.APP_NAME)

channelInactive

  • 類似TCP四次揮手之後,等待2MSL時間之後觸發(大概180s),比如channel通道關閉會觸發(channel.close())該方法,釋出ChannelInactiveEvent事件,來10s後重連

channelRead0

  • 接收PONG訊息型別時,打個日誌返回

  • 接收RESPONSE_NEW_KEY訊息型別時,釋出ReceiveNewKeyEvent事件

3.3.3 worker端

1.入口啟動載入:7個@PostConstruct

1)worker端對etcd相關的處理:EtcdStarter 第一個@PostConstruct:watchLog()


@PostConstruct
public void watchLog() {
    AsyncPool.asyncDo(() -> {
        try {
            //取etcd的是否開啟日誌配置,地址/jd/logOn
            String loggerOn = configCenter.get(ConfigConstant.logToggle);
            LOGGER_ON = "true".equals(loggerOn) || "1".equals(loggerOn);
        } catch (StatusRuntimeException ex) {
            logger.error(ETCD_DOWN);
        }
        //監聽etcd地址/jd/logOn是否開啟日誌配置,並實時更改開關
        KvClient.WatchIterator watchIterator = configCenter.watch(ConfigConstant.logToggle);
        while (watchIterator.hasNext()) {
            WatchUpdate watchUpdate = watchIterator.next();
            List<Event> eventList = watchUpdate.getEvents();
            KeyValue keyValue = eventList.get(0).getKv();
            logger.info("log toggle changed : " + keyValue);
            String value = keyValue.getValue().toStringUtf8();
            LOGGER_ON = "true".equals(value) || "1".equals(value);
        }
    });
}


  • 放到執行緒池裡面非同步執行

  • 取etcd的是否開啟日誌配置,地址/jd/logOn,預設true

  • 監聽etcd地址/jd/logOn是否開啟日誌配置,並實時更改開關

  • 由於有etcd的監聽,所以會一直執行,而不是執行一次結束

第二個@PostConstruct:watch()


/**
 * 啟動回撥監聽器,監聽rule變化
 */
@PostConstruct
public void watch() {
    AsyncPool.asyncDo(() -> {
        KvClient.WatchIterator watchIterator;
        if (isForSingle()) {
            watchIterator = configCenter.watch(ConfigConstant.rulePath + workerPath);
        } else {           
            watchIterator = configCenter.watchPrefix(ConfigConstant.rulePath);
        }
        while (watchIterator.hasNext()) {
            WatchUpdate watchUpdate = watchIterator.next();
            List<Event> eventList = watchUpdate.getEvents();
            KeyValue keyValue = eventList.get(0).getKv();
            logger.info("rule changed : " + keyValue);
            try {
                ruleChange(keyValue);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    });
}
/**
     * rule發生變化時,更新快取的rule
     */
    private synchronized void ruleChange(KeyValue keyValue) {
        String appName = keyValue.getKey().toStringUtf8().replace(ConfigConstant.rulePath, "");
        if (StrUtil.isEmpty(appName)) {
            return;
        }
        String ruleJson = keyValue.getValue().toStringUtf8();
        List<KeyRule> keyRules = FastJsonUtils.toList(ruleJson, KeyRule.class);
        KeyRuleHolder.put(appName, keyRules);
    }


透過etcd.workerPath配置,來判斷該worker是否為某個app單獨服務的,預設為”default”,如果是預設值,代表該worker參與在etcd上所有app client的計算,否則只為某個app來服務計算

使用etcd來監聽rule規則變化,如果是共享的worker,監聽地址字首為”/jd/rules/“,如果為某個app獨享,監聽地址為”/jd/rules/“+$etcd.workerPath

如果規則變化,則修改對應app在本地儲存的rule快取,同時清理該app在本地儲存的KV快取

KeyRuleHolder:rule快取本地儲存

  • Map> RULE_MAP,這個map是concurrentHashMap,map的kv分別是appName和對應的rule

  • 相對於client的KeyRuleHolder的區別:worker是儲存所有app規則,每個app對應一個規則桶,所以用map

CaffeineCacheHolder:key快取本地儲存

  • Map> CACHE_MAP,也是concurrentHashMap,map的kv分別是appName和對應的kv的caffeine

  • 相對於client的caffeine,第一是worker沒有做快取介面比如LocalCache,第二是client的map的kv分別是超時時間、以及相同超時時間所對應key的快取桶

放到執行緒池裡面非同步執行,由於有etcd的監聽,所以會一直執行,而不是執行一次結束

第三個@PostConstruct:watchWhiteList()


/**
 * 啟動回撥監聽器,監聽白名單變化,只監聽自己所在的app,白名單key不參與熱key計算,直接忽略
 */
@PostConstruct
public void watchWhiteList() {
    AsyncPool.asyncDo(() -> {
        //從etcd配置中獲取所有白名單
        fetchWhite();
        KvClient.WatchIterator watchIterator = configCenter.watch(ConfigConstant.whiteListPath + workerPath);
        while (watchIterator.hasNext()) {
            WatchUpdate watchUpdate = watchIterator.next();
            logger.info("whiteList changed ");
            try {
                fetchWhite();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    });
}


  • 拉取並監聽etcd白名單key配置,地址為/jd/whiteList/+$etcd.workerPath

  • 在白名單的key,不參與熱key計算,直接忽略

  • 放到執行緒池裡面非同步執行,由於有etcd的監聽,所以會一直執行,而不是執行一次結束

第四個@PostConstruct:makeSureSelfOn()


/**
 * 每隔一會去check一下,自己還在不在etcd裡
 */
@PostConstruct
public void makeSureSelfOn() {
    //開啟上傳worker資訊
    ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
    scheduledExecutorService.scheduleAtFixedRate(() -> {
        try {
            if (canUpload) {
                uploadSelfInfo();
            }
        } catch (Exception e) {
            //do nothing
        }
    }, 0, 5, TimeUnit.SECONDS);
}


  • 線上程池裡面非同步執行,定時執行,時間間隔為5s

  • 將本機woker的hostName,ip+port以kv的形式定時上報給etcd,地址為/jd/workers/+$etcd.workPath+”/“+$hostName,續期時間為8s

  • 有一個canUpload的開關來控制worker是否向etcd來定時續期,如果這個開關關閉了,代表worker不向etcd來續期,這樣當上面地址的kv到期之後,etcd會刪除該節點,這樣client迴圈判斷worker資訊變化了

2)將熱key推送到dashboard供入庫:DashboardPusher

第五個@PostConstruct:uploadToDashboard()


@Component
public class DashboardPusher implements IPusher {
    /**
     * 熱key集中營
     */
    private static LinkedBlockingQueue<HotKeyModel> hotKeyStoreQueue = new LinkedBlockingQueue<>();

    @PostConstruct
    public void uploadToDashboard() {
        AsyncPool.asyncDo(() -> {
            while (true) {
                try {
                    //要麼key達到1千個,要麼達到1秒,就彙總上報給etcd一次
                    List<HotKeyModel> tempModels = new ArrayList<>();
                    Queues.drain(hotKeyStoreQueue, tempModels, 1000, 1, TimeUnit.SECONDS);
                    if (CollectionUtil.isEmpty(tempModels)) {
                        continue;
                    }

                    //將熱key推到dashboard
                    DashboardHolder.flushToDashboard(FastJsonUtils.convertObjectToJSON(tempModels));
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
    }
}


  • 當熱key的數量達到1000或者每隔1s,把熱key的資料透過與dashboard的netty通道來傳送給dashboard,資料型別為REQUEST_HOT_KEY

  • LinkedBlockingQueue hotKeyStoreQueue:worker計算的給dashboard熱key的集中營,所有給dashboard推送熱key儲存在裡面

3)推送到各客戶端伺服器:AppServerPusher

第六個@PostConstruct:batchPushToClient()


public class AppServerPusher implements IPusher {
    /**
     * 熱key集中營
     */
    private static LinkedBlockingQueue<HotKeyModel> hotKeyStoreQueue = new LinkedBlockingQueue<>();

    /**
     * 和dashboard那邊的推送主要區別在於,給app推送每10ms一次,dashboard那邊1s一次
     */
    @PostConstruct
    public void batchPushToClient() {
        AsyncPool.asyncDo(() -> {
            while (true) {
                try {
                    List<HotKeyModel> tempModels = new ArrayList<>();
                    //每10ms推送一次
                    Queues.drain(hotKeyStoreQueue, tempModels, 10, 10, TimeUnit.MILLISECONDS);
                    if (CollectionUtil.isEmpty(tempModels)) {
                        continue;
                    }
                    Map<String, List<HotKeyModel>> allAppHotKeyModels = new HashMap<>();
                    //拆分出每個app的熱key集合,按app分堆
                    for (HotKeyModel hotKeyModel : tempModels) {
                        List<HotKeyModel> oneAppModels = allAppHotKeyModels.computeIfAbsent(hotKeyModel.getAppName(), (key) -> new ArrayList<>());
                        oneAppModels.add(hotKeyModel);
                    }
                    //遍歷所有app,進行推送
                    for (AppInfo appInfo : ClientInfoHolder.apps) {
                        List<HotKeyModel> list = allAppHotKeyModels.get(appInfo.getAppName());
                        if (CollectionUtil.isEmpty(list)) {
                            continue;
                        }
                        HotKeyMsg hotKeyMsg = new HotKeyMsg(MessageType.RESPONSE_NEW_KEY);
                        hotKeyMsg.setHotKeyModels(list);

                        //整個app全部傳送
                        appInfo.groupPush(hotKeyMsg);
                    }
                    //推送完,及時清理不使用記憶體
                    allAppHotKeyModels = null;
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
    }
}


  • 會按照key的appName來進行分組,然後透過對應app的channelGroup來推送

  • 當熱key的數量達到10或者每隔10ms,把熱key的資料透過與app的netty通道來傳送給app,資料型別為RESPONSE_NEW_KEY

  • LinkedBlockingQueue hotKeyStoreQueue:worker計算的給client熱key的集中營,所有給client推送熱key儲存在裡面

4)client例項節點處理:NodesServerStarter 第七個@PostConstruct:start()


public class NodesServerStarter {
    @Value("${netty.port}")
    private int port;
    private Logger logger = LoggerFactory.getLogger(getClass());
    @Resource
    private IClientChangeListener iClientChangeListener;
    @Resource
    private List<INettyMsgFilter> messageFilters;

    @PostConstruct
    public void start() {
        AsyncPool.asyncDo(() -> {
            logger.info("netty server is starting");
            NodesServer nodesServer = new NodesServer();
            nodesServer.setClientChangeListener(iClientChangeListener);
            nodesServer.setMessageFilters(messageFilters);
            try {
                nodesServer.startNettyServer(port);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
}


  • 執行緒池裡面非同步執行,啟動client端的nettyServer

  • iClientChangeListener和messageFilters這兩個依賴最終會被傳遞到netty訊息處理器裡面,iClientChangeListener會作為channel下線處理來刪除ClientInfoHolder下線或者超時的通道,messageFilters會作為netty收到事件訊息的處理過濾器(責任鏈模式)

依賴的bean:IClientChangeListener iClientChangeListener


public interface IClientChangeListener {
    /**
     * 發現新連線
     */
    void newClient(String appName, String channelId, ChannelHandlerContext ctx);
    /**
     * 客戶端掉線
     */
    void loseClient(ChannelHandlerContext ctx);
}


對客戶端的管理,新來(newClient)(會觸發netty的連線方法channelActive)、斷線(loseClient)(會觸發netty的斷連方法channelInactive())的管理client的連線資訊主要是在ClientInfoHolder裡面

  • List apps,這裡面的AppInfo主要是appName和對應的channelGroup

  • 對apps的add和remove主要是透過新來(newClient)、斷線(loseClient)

依賴的bean:List messageFilters


/**
 * 對netty來的訊息,進行過濾處理
 * @author wuweifeng wrote on 2019-12-11
 * @version 1.0
 */
public interface INettyMsgFilter {
    boolean chain(HotKeyMsg message, ChannelHandlerContext ctx);
}


對client發給worker的netty訊息,進行過濾處理,共有四個實現類,也就是說底下四個過濾器都是收到client傳送的netty訊息來做處理

各個訊息處理的型別:MessageType


APP_NAME((byte) 1),
REQUEST_NEW_KEY((byte) 2),
RESPONSE_NEW_KEY((byte) 3),
REQUEST_HIT_COUNT((byte) 7), //命中率
REQUEST_HOT_KEY((byte) 8), //熱key,worker->dashboard
PING((byte) 4), PONG((byte) 5),
EMPTY((byte) 6);


順序1:HeartBeatFilter

  • 當訊息型別為PING,則給對應的client示例返回PONG

順序2:AppNameFilter

  • 當訊息型別為APP_NAME,代表client與worker建立連線成功,然後呼叫iClientChangeListener的newClient方法增加apps後設資料資訊

順序3:HotKeyFilter

  • 處理接收訊息型別為REQUEST_NEW_KEY

  • 先給HotKeyFilter.totalReceiveKeyCount原子類增1,該原子類代表worker例項接收到的key的總數

  • publishMsg方法,將訊息透過自建的生產者消費者模型(KeyProducer,KeyConsumer),來把訊息給發到生產者中分發消費

  • 接收到的訊息HotKeyMsg裡面List

  • 首先判斷HotKeyModel裡面的key是否在白名單內,如果在則跳過,否則將HotKeyModel透過KeyProducer傳送

順序4:KeyCounterFilter

  • 處理接收型別為REQUEST_HIT_COUNT

  • 這個過濾器是專門給dashboard來彙算key的,所以這個appName直接設定為該worker配置的appName

  • 該過濾器的資料來源都是client的NettyKeyPusher#sendCount(String appName, List list),這裡面的資料都是預設積攢10s的,這個10s是可以配置的,這一點在client裡面有講

  • 將構造的new KeyCountItem(appName, models.get(0).getCreateTime(), models)放到阻塞佇列LinkedBlockingQueue COUNTER_QUEUE中,然後讓CounterConsumer來消費處理,消費邏輯是單執行緒的

  • CounterConsumer:熱key統計消費者

  • 放在公共執行緒池中,來單執行緒執行

  • 從阻塞佇列COUNTER_QUEUE裡面取資料,然後將裡面的key的統計資料釋出到etcd的/jd/keyHitCount/+ appName + “/“ + IpUtils.getIp() + “-“ + System.currentTimeMillis()裡面,該路徑是worker服務的client叢集或者default,用來存放客戶端hotKey訪問次數和總訪問次數的path,然後讓dashboard來訂閱統計展示

2.三個定時任務:3個@Scheduled

1)定時任務1:EtcdStarter#pullRules()


/**
 * 每隔1分鐘拉取一次,所有的app的rule
 */
@Scheduled(fixedRate = 60000)
public void pullRules() {
    try {
        if (isForSingle()) {
            String value = configCenter.get(ConfigConstant.rulePath + workerPath);
            if (!StrUtil.isEmpty(value)) {
                List<KeyRule> keyRules = FastJsonUtils.toList(value, KeyRule.class);
                KeyRuleHolder.put(workerPath, keyRules);
            }
        } else {
            List<KeyValue> keyValues = configCenter.getPrefix(ConfigConstant.rulePath);
            for (KeyValue keyValue : keyValues) {
                ruleChange(keyValue);
            }
        }
    } catch (StatusRuntimeException ex) {
        logger.error(ETCD_DOWN);
    }
}


每隔1分鐘拉取一次etcd地址為/jd/rules/的規則變化,如果worker所服務的app或者default的rule有變化,則更新規則的快取,並清空該appName所對應的本地key快取

2)定時任務2:EtcdStarter#uploadClientCount()


/**
     * 每隔10秒上傳一下client的數量到etcd中
     */
    @Scheduled(fixedRate = 10000)
    public void uploadClientCount() {
        try {
            String ip = IpUtils.getIp();
            for (AppInfo appInfo : ClientInfoHolder.apps) {
                String appName = appInfo.getAppName();
                int count = appInfo.size();
                //即便是full gc也不能超過3秒,因為這裡給的過期時間是13s,由於該定時任務每隔10s執行一次,如果full gc或者說上報給etcd的時間超過3s,
                //則在dashboard查詢不到client的數量
                configCenter.putAndGrant(ConfigConstant.clientCountPath + appName + "/" + ip, count + "", 13);
            }
            configCenter.putAndGrant(ConfigConstant.caffeineSizePath + ip, FastJsonUtils.convertObjectToJSON(CaffeineCacheHolder.getSize()), 13);
            //上報每秒QPS(接收key數量、處理key數量)
            String totalCount = FastJsonUtils.convertObjectToJSON(new TotalCount(HotKeyFilter.totalReceiveKeyCount.get(), totalDealCount.longValue()));
            configCenter.putAndGrant(ConfigConstant.totalReceiveKeyCount + ip, totalCount, 13);
            logger.info(totalCount + " expireCount:" + expireTotalCount + " offerCount:" + totalOfferCount);
            //如果是穩定一直有key傳送的應用,建議開啟該監控,以避免可能發生的網路故障
            if (openMonitor) {
                checkReceiveKeyCount();
            }
//            configCenter.putAndGrant(ConfigConstant.bufferPoolPath + ip, MemoryTool.getBufferPool() + "", 10);
        } catch (Exception ex) {
            logger.error(ETCD_DOWN);
        }
    }


  • 每個10s將worker計算儲存的client資訊上報給etcd,來方便dashboard來查詢展示,比如/jd/count/對應client數量,/jd/caffeineSize/對應caffeine快取的大小,/jd/totalKeyCount/對應該worker接收的key總量和處理的key總量

  • 可以從程式碼中看到,上面所有etcd的節點租期時間都是13s,而該定時任務是每10s執行一次,意味著如果full gc或者說上報給etcd的時間超過3s,則在dashboard查詢不到client的相關彙算資訊

  • 長時間不收到key,判斷網路狀態不好,斷開worker給etcd地址為/jd/workers/+$workerPath節點的續租,因為client會迴圈判斷該地址的節點是否變化,使得client重新連線worker或者斷開失聯的worker

3)定時任務3:EtcdStarter#fetchDashboardIp()


/**
 * 每隔30秒去獲取一下dashboard的地址
 */
@Scheduled(fixedRate = 30000)
public void fetchDashboardIp() {
    try {
        //獲取DashboardIp
        List<KeyValue> keyValues = configCenter.getPrefix(ConfigConstant.dashboardPath);
        //是空,給個警告
        if (CollectionUtil.isEmpty(keyValues)) {
            logger.warn("very important warn !!! Dashboard ip is null!!!");
            return;
        }
        String dashboardIp = keyValues.get(0).getValue().toStringUtf8();
        NettyClient.getInstance().connect(dashboardIp);
    } catch (Exception e) {
        e.printStackTrace();
    }
}


每隔30s拉取一次etcd字首為/jd/dashboard/的dashboard連線ip的值,並且判斷DashboardHolder.hasConnected裡面是否為未連線狀態,如果是則重新連線worker與dashboard的netty通道

3.自建的生產者消費者模型(KeyProducer,KeyConsumer)

一般生產者消費者模型包含三大元素:生產者、消費者、訊息儲存佇列這裡訊息儲存佇列是DispatcherConfig裡面的QUEUE,使用LinkedBlockingQueue,預設大小為200W

1)KeyProducer


@Component
public class KeyProducer {
    public void push(HotKeyModel model, long now) {
        if (model == null || model.getKey() == null) {
            return;
        }
        //5秒前的過時訊息就不處理了
        if (now - model.getCreateTime() > InitConstant.timeOut) {
            expireTotalCount.increment();
            return;
        }
        try {
            QUEUE.put(model);
            totalOfferCount.increment();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}


判斷接收到的HotKeyModel是否超出”netty.timeOut”配置的時間,如果是將expireTotalCount紀錄過期總數給自增,然後返回

2)KeyConsumer


public class KeyConsumer {
    private IKeyListener iKeyListener;
    public void setKeyListener(IKeyListener iKeyListener) {
        this.iKeyListener = iKeyListener;
    }
    public void beginConsume() {
        while (true) {
            try {
                //從這裡可以看出,這裡的生產者消費者模型,本質上還是拉模式,之所以不使用EventBus,是因為需要佇列來做緩衝
                HotKeyModel model = QUEUE.take();
                if (model.isRemove()) {
                    iKeyListener.removeKey(model, KeyEventOriginal.CLIENT);
                } else {
                    iKeyListener.newKey(model, KeyEventOriginal.CLIENT);
                }
                //處理完畢,將數量加1
                totalDealCount.increment();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
@Override
public void removeKey(HotKeyModel hotKeyModel, KeyEventOriginal original) {
   //cache裡的key,appName+keyType+key
   String key = buildKey(hotKeyModel);
   hotCache.invalidate(key);
   CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).invalidate(key);
   //推送所有client刪除
   hotKeyModel.setCreateTime(SystemClock.now());
   logger.info(DELETE_KEY_EVENT + hotKeyModel.getKey());
   for (IPusher pusher : iPushers) {
       //這裡可以看到,刪除熱key的netty訊息只給client端發了過去,沒有給dashboard發過去(DashboardPusher裡面的remove是個空方法)
       pusher.remove(hotKeyModel);
   }
}
    @Override
    public void newKey(HotKeyModel hotKeyModel, KeyEventOriginal original) {
        //cache裡的key
        String key = buildKey(hotKeyModel);
        //判斷是不是剛熱不久
        //hotCache對應的caffeine有效期為5s,也就是說該key會儲存5s,在5s內不重複處理相同的hotKey。
        //畢竟hotKey都是瞬時流量,可以避免在這5s內重複推送給client和dashboard,避免無效的網路開銷
        Object o = hotCache.getIfPresent(key);
        if (o != null) {
            return;
        }

        //********** watch here ************//
        //該方法會被InitConstant.threadCount個執行緒同時呼叫,存在多執行緒問題
        //下面的那句addCount是加了鎖的,代表給Key累加數量時是原子性的,不會發生多加、少加的情況,到了設定的閾值一定會hot
        //譬如閾值是2,如果多個執行緒累加,在沒hot前,hot的狀態肯定是對的,譬如thread1 加1,thread2加1,那麼thread2會hot返回true,開啟推送
        //但是極端情況下,譬如閾值是10,當前是9,thread1走到這裡時,加1,返回true,thread2也走到這裡,加1,此時是11,返回true,問題來了
        //該key會走下面的else兩次,也就是2次推送。
        //所以出現問題的原因是hotCache.getIfPresent(key)這一句在併發情況下,沒return掉,放了兩個key+1到addCount這一步時,會有問題
        //測試程式碼在TestBlockQueue類,直接執行可以看到會同時hot

        //那麼該問題用解決嗎,NO,不需要解決,1 首先要發生的條件極其苛刻,很難觸發,以京東這樣高的併發量,線上我也沒見過觸發連續2次推送同一個key的
        //2 即便觸發了,後果也是可以接受的,2次推送而已,毫無影響,客戶端無感知。但是如果非要解決,就要對slidingWindow例項加鎖了,必然有一些開銷

        //所以只要保證key數量不多計算就可以,少計算了沒事。因為熱key必然頻率高,漏計幾次沒事。但非熱key,多計算了,被幹成了熱key就不對了
        SlidingWindow slidingWindow = checkWindow(hotKeyModel, key);//從這裡可知,每個app的每個key都會對應一個滑動視窗
        //看看hot沒
        boolean hot = slidingWindow.addCount(hotKeyModel.getCount());

        if (!hot) {
            //如果沒hot,重新put,cache會自動重新整理過期時間
            CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).put(key, slidingWindow);
        } else {
            //這裡之所以放入的value為1,是因為hotCache是用來專門儲存剛生成的hotKey
            //hotCache對應的caffeine有效期為5s,也就是說該key會儲存5s,在5s內不重複處理相同的hotKey。
            //畢竟hotKey都是瞬時流量,可以避免在這5s內重複推送給client和dashboard,避免無效的網路開銷
            hotCache.put(key, 1);

            //刪掉該key
            //這個key從實際上是專門針對slidingWindow的key,他的組合邏輯是appName+keyType+key,而不是給client和dashboard推送的hotKey
            CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).invalidate(key);

            //開啟推送
            hotKeyModel.setCreateTime(SystemClock.now());

            //當開關開啟時,列印日誌。大促時關閉日誌,就不列印了
            if (EtcdStarter.LOGGER_ON) {
                logger.info(NEW_KEY_EVENT + hotKeyModel.getKey());
            }

            //分別推送到各client和etcd
            for (IPusher pusher : iPushers) {
                pusher.push(hotKeyModel);
            }

        }

    }


“thread.count”配置即為消費者個數,多個消費者共同消費一個QUEUE佇列生產者消費者模型,本質上還是拉模式,之所以不使用EventBus,是因為需要佇列來做緩衝根據HotKeyModel裡面是否是刪除訊息型別

  • 刪除訊息型別

  • 根據HotKeyModel裡面的appName+keyType+key的名字,來構建caffeine裡面的newkey,該newkey在caffeine裡面主要是用來與slidingWindow滑動時間窗對應

  • 刪除hotCache裡面newkey的快取,放入的快取kv分別是newKey和1,hotCache作用是用來儲存該生成的熱key,hotCache對應的caffeine有效期為5s,也就是說該key會儲存5s,在5s內不重複處理相同的hotKey。畢竟hotKey都是瞬時流量,可以避免在這5s內重複推送給client和dashboard,避免無效的網路開銷

  • 刪除CaffeineCacheHolder裡面對應appName的caffeine裡面的newKey,這裡面儲存的是slidingWindow滑動視窗

  • 推送給該HotKeyModel對應的所有client例項,用來讓client刪除該HotKeyModel

  • 非刪除訊息型別

  • 根據HotKeyModel裡面的appName+keyType+key的名字,來構建caffeine裡面的newkey,該newkey在caffeine裡面主要是用來與slidingWindow滑動時間窗對應

  • 透過hotCache來判斷該newkey是否剛熱不久,如果是則返回

  • 根據滑動時間視窗來計算判斷該key是否為hotKey(這裡可以學習一下滑動時間視窗的設計),並返回或者生成該newKey對應的滑動視窗

  • 如果沒有達到熱key的標準

  • 透過CaffeineCacheHolder重新put,cache會自動重新整理過期時間

  • 如果達到了熱key標準

  • 向hotCache裡面增加newkey對應的快取,value為1表示剛為熱key。

  • 刪除CaffeineCacheHolder裡面對應newkey的滑動視窗快取。

  • 向該hotKeyModel對應的app的client推送netty訊息,表示新產生hotKey,使得client本地快取,但是推送的netty訊息只代表為熱key,client本地快取不會儲存key對應的value值,需要呼叫JdHotKeyStore裡面的api來給本地快取的value賦值

  • 向dashboard推送hotKeyModel,表示新產生hotKey

3)計算熱key滑動視窗的設計限於篇幅的原因,這裡就不細談了,直接貼出專案作者對其寫的說明文章:Java簡單實現滑動視窗

3.3.4 dashboard端

這個沒啥可說的了,就是連線etcd、mysql,增刪改查,不過京東的前端框架很方便,直接返回list就可以成列表。

4 總結

文章第二部分為大家講解了redis資料傾斜的原因以及應對方案,並對熱點問題進行了深入,從發現熱key到解決熱key的兩個關鍵問題的總結。

文章第三部分是熱key問題解決方案——JD開源hotkey的原始碼解析,分別從client端、worker端、dashboard端來進行全方位講解,包括其設計、使用及相關原理。

希望透過這篇文章,能夠使大家不僅學習到相關方法論,也能明白其方法論具體的落地方案,一起學習,一起成長。


作者:李鵬


相關文章