SOFARegistry 原始碼|資料分片之核心-路由表 SlotTable 剖析

SOFAStack發表於2022-04-19

文|程徵徵(花名:澤睿 )

高德軟體開發工程師

負責高德新場景業務探索開發與維護 對領域驅動、網路通訊、資料一致性有一定的研究與實踐

本文 23009字 閱讀約 25 分鐘

第一次關注 SOFA 社群是在開發一個故障剔除元件時,發現 SOFARPC 中也有類似的元件。在 SOFARPC 的設計中,入口採用了一種無縫插入的設計方式,使得在不破壞開放封閉原則前提下,引入單機故障剔除能力。並且是基於核心設計和匯流排設計,做到可插拔、零侵入,整個故障剔除模組是通過 SPI 動態載入的。統計資訊的收集也是通過事件驅動的方式,在 RPC 同步或非同步呼叫完成後,會向事件匯流排 EventBus 傳送對應事件。事件匯流排接收到對應的事件,以執行後續的故障剔除邏輯。

基於以上優秀的設計,我也將其納為己用,也因此開啟了在 SOFA 社群的開源探索之路。陸續研究了 SOFABoot、SOFARPC 以及 MOSN 等,自我感覺每一個專案的程式碼水平都很高,對我自己的程式碼提升有很大的幫助。

SOFARegistry 是一個開源的註冊中心提供了服務的釋出註冊訂閱等功能,支援海量的服務註冊訂閱請求。作為一個名原始碼愛好者,雖然看過 SOFA 的架構文章大致瞭解其中的設計哲學,但是因為沒有從程式碼中瞭解過細節,實際上也是一知半解。恰好藉助 SOFARegistry 開闢的原始碼分析活動,基於自己的興趣選擇了 SlotTable 這個任務。

SOFARegistry 對於服務資料是分片進行儲存的,因此每一個 data server 只會承擔一部分的服務資料,具體哪份資料儲存在哪個 data server 是有一個稱為 SlotTable 的路由表提供的,session 可以通過 SlotTable 對對應的 data derver 進行讀寫服務資料, slot 對應的 data follower 可以通過 SlotTable 定址 leader 進行資料同步。

維護 SlotTable 是由 Meta 的 leader 負責的,Meta 會維護 data 的列表,會利用這份列表以及 data 上報的監控資料建立 SlotTable,後續 data 的上下線會觸發 Meta 修改 SlotTable, SlotTable 會通過心跳分發給叢集中各個節點。

貢獻者前言

SOFARegistry 對於服務資料是分片進行儲存的,因此每一個 data server 只會承擔一部分的服務資料,具體哪份資料儲存在哪個 data server 是有一個稱為 SlotTable 的路由表提供的,session 可以通過 SlotTable 對對應的 data derver 進行讀寫服務資料, slot 對應的 data follower 可以通過 SlotTable 定址 leader 進行資料同步。

維護 SlotTable 是由 Meta 的 leader 負責的,Meta 會維護 data 的列表,會利用這份列表以及 data 上報的監控資料建立 SlotTable,後續 data 的上下線會觸發 Meta 修改 SlotTable, SlotTable 會通過心跳分發給叢集中各個節點。

SlotTable 在 SOFARegistry 是非常核心的概念,簡稱路由表。簡單來說 SOFARegistry 是需要將釋出訂閱資料儲存在不同的機器節點上,才能保證資料儲存的橫向擴充套件。那麼不同機器上到底儲存哪些資料,就是 SlotTable 來儲存的。

SlotTable 儲存了 Slot 和機器節點之間的對映關係,資料通過 Hash 定位到某一個 Slot 上,通過 Slot 找到對應的機器 node 節點,將資料儲存到對應的機器上。在這過程中有很多細節需要我們瞭解。比如說每一個 Slot 對應 leader 和 follow 節點是如何分配的。如果機器負載不平衡該如何平衡,SlotTable 的更新是如何進行的呢? 這些都是很有趣的細節實現。

1. DataServer 更新SlotTable 路由表過程。

image.png

如上圖所示 session 和 data 節點定時會向 Meta 節點上報心跳、Meta節點維護了 data 以及 session 節點列表資訊、並且在心跳請求中將返回 SlotTable 路由表資訊、data 節點將路由表 SlotTable 儲存在本地中。

2. SlotTable 更新平衡演算法

由前文可知、SOFARegistry 採用了資料分片儲存在 DataServer 節點之上、那麼隨之而來的問題就是資料如何分片呢?

SOFARegistry 採用預分配的方式。

傳統的一致性 Hash 演算法有資料分佈範圍不固定的特性,該特性使得服務註冊資料在伺服器節點當機、下線、擴容之後,需要重新儲存排布,這為資料的同步帶來了困難。大多數的資料同步操作是利用操作日誌記錄的內容來進行的,傳統的一致性 Hash 演算法中,資料的操作日誌是以節點分片來劃分的,節點變化導致資料分佈範圍的變化。

在計算機領域,大多數難題都可以通過增加一箇中間層來解決,那麼對於資料分佈範圍不固定所導致的資料同步難題,也可以通過同樣的思路來解決。

這裡的問題在於,當節點下線後,若再以當前存活節點 ID 一致性 Hash 值去同步資料,就會導致已失效節點的資料操作日誌無法獲取到,既然資料儲存在會變化的地方無法進行資料同步,那麼如果把資料儲存在不會變化的地方是否就能保證資料同步的可行性呢?答案是肯定的,這個中間層就是預分片層,通過把資料與預分片這個不會變化的層相互對應就能解決這個資料同步的難題。

目前業界主要代表專案如 Dynamo、Casandra、Tair、Codis、Redis cluster 等,都採用了預分片機制來實現這個不會變化的層。

事先將資料儲存範圍等分為 N 個 slot 槽位,資料直接與 slot 相對應,資料的操作日誌與相應的 solt 對應,slot 的數目不會因為節點的上下線而產生變化,由此保證了資料同步的可行性。除此之外,還需要引進“路由表”的概念,如圖 13,“路由表”負責存放每個節點和 N 個 slot 的對映關係,並保證儘量把所有 slot 均勻地分配給每個節點。這樣,當節點上下線時,只需要修改路由表內容即可。保持 slot 不變,即保證了彈性擴縮容,也大大降低了資料同步的難度。

image.png

實際上上述 Slot節點 的對映關係在原始碼中以 SlotTable 和 Slot 的方式進行表達。原始碼如下程式碼塊所示。


public final class SlotTable implements Serializable {
  public static final SlotTable INIT = new SlotTable(-1, Collections.emptyList());
  // 最後一次更新的時間 epoch
  private final long epoch;
  //儲存了 所有的 slot 資訊; slotId ---> slot 物件的對映
  private final Map<Integer, Slot> slots;
}
public final class Slot implements Serializable, Cloneable {
  public enum Role {
    Leader,
    Follower,
  }
  private final int id;
  //當前slot的leader節點
  private final String leader;
  //最近更新時間
  private final long leaderEpoch;
  //當前slot的follow節點
  private final Set<String> followers;
}

由於節點在動態變化中、所以 Slot 和 節點的對映也在時刻變化中、那麼我們接下來的重點就是 SlotTable 的變更過程。SlotTable 的變更是在 Meta 節點中觸發、當有服務上下線的時候會觸發SlotTable 的變更、除此之外也會定期執執行 SlotTable的變更。

SlotTable的整個同步更新步驟如圖所示。

程式碼參考
com.alipay.sofa.registry.server.Meta.slot.arrange.ScheduledSlotArranger#arrangeSync.

SlotTable 的定期變更是通過在初始化 ScheduledSlotArranger 時候例項化守護執行緒不斷的 定期執行 內部任務 Arranger 的 arrangeSync 方法來實現 SlotTable 變更的。大致流程如下所示。

image.png

因為負責 SlotTable 的更新是在 MetaServer 中的主節點更新的。

所以更新 SlotTable的第一步就是判斷是否是主節點。主節點才負責真正的 SlotTable 變更步驟。

第二步是獲取最新的 DataServer 節點,因為 重新分配 SlotTable 本質上是 對 DataServer 節點和 slot 槽位之間的對映關係進行重新分配。所以肯定需要獲取到當前正在存活的 DataServer 節點資訊,從而方便的對之進行 slot 分配。

(這裡獲取正在存活的 DataServer 也就是有和 MetaServer 維持心跳的 DataServer, 底層是從
com.alipay.sofa.registry.server.Meta.lease.impl.SimpleLeaseManager中獲取,感興趣可以檢視相關原始碼) 。

第三部是分配前置校驗,實際上一些邊界條件的判斷、例如 DataServer 是否為空、 DataServer 的大小是否大於配置的 minDataNodeNum,只有滿足這些條件才進行變更。

第四步 執行 trayArrageSlot 方法、進入到該方法內部之中。

首先獲取程式內部鎖、實際上是一個 ReentrantLock,這裡主要是為了避免定時任務多次同時執行 SlotTable 的分配工作。

private final Lock lock = new ReentrantLock();

隨後便是根據當前的 Data 節點資訊建立 SlotTableBuilder、這裡的 SlotTableBuilder 又是何方神聖呢?回到 SlotTable 更新的方式、一般是建立一個新的 SlotTable 物件、然後用這個新建立的物件去代替老的 SlotTable 物件、從而完成變更 SlotTable 操作、一般不會直接對老的SlotTable直接進行增刪該 操作、這樣併發導致的一致性問題很難控制。所以基於此、SlotTableBuilder 從它的名稱就可以看出 它是 SlotTable 的建立者、內部聚合了SlotBuilder 物件。其實和 SlotTable 類似的、SlotTable 內部聚合了 Slot 資訊。

在檢視 SlotTable 變更演算法之前、我們先了解一下 SlotTableBuilder 的建立過程。SlotBuilder 的結構如下所示。

public class SlotTableBuilder {
  //當前正在建立的 Slot 資訊
  private final Map<Integer, SlotBuilder> buildingSlots = Maps.newHashMapWithExpectedSize(256);
  // 反向查詢索引資料、通過 節點查詢該節點目前負責哪些 slot 的資料的管理。
  private final Map<String, DataNodeSlot> reverseMap = Maps.newHashMap();
  //slot 槽的個數
  private final int slotNums;
  //follow 節點的數量
  private final int followerNums;
  //最近一次更新的時間
  private long epoch;
}

SlotTableBuilder 可以看出內部聚合了一個 buildingSlots 、標識正在建立的 Slot。因為 SlotTable 是由 Slot 構成的、這點也很容易理解。除此之外 SlotTableBuilder 內部也聚合了一個 reverseMap,代表反向查詢索引,這個對映的 key是 dataServer、value是 DataNodeSlot 物件. DataNodeSlot 原始碼如下。

/**
    通過 Slot 找 leader和follows.
    本質上是通過節點找 Slot,當前節點作為leaders的slot、和以當前節點作為 follower 的節點.
    也就是說 當前我這個節點、我在那些 slot 中作為 leader, 對應的是 Set<Integer> leaders.
    以及我當前這個節點在哪些 slot 中作為 follow,對應儲存在 Set<Integer> follows.
**/
public final class DataNodeSlot  {
  private final String dataNode;
  private final Set<Integer> leaders = Sets.newTreeSet();
  private final Set<Integer> followers = Sets.newTreeSet();
}

用一張圖來表達 DataNodeSlot 如下所示。可見它和圖1是剛好相反的對映。通過節點查詢 與該節點有關聯的 slot資訊、因為後面要經常用到這一層查詢、所以直接將這種關係儲存下來。為了後面陳述方便、這裡統計幾種陳述方式。

  1. 節點被作為leader 的slot集合我們稱為 : 節點 leader 的slot集合。
  2. 節點被作為follow 的slot集合我們稱為 : 節點 follow 的slot集合。
  3. SlotTable 關聯的所有節點統稱為: SlotTable 的節點列表

image.png

再回到 SlotTableBuilder 建立

private SlotTableBuilder createSlotTableBuilder(SlotTable slotTable, 
                                                List<String> currentDataNodeIps,
                                                int slotNum,int replicas) {
    //通過 NodeComparator 包裝當前新增的、刪除的的節點.
    NodeComparator comparator = new NodeComparator(slotTable.getDataServers(), currentDataNodeIps);
    SlotTableBuilder slotTableBuilder = new SlotTableBuilder(slotTable, slotNum, replicas);
    //執行 slotTableBuilder 的初始化
    slotTableBuilder.init(currentDataNodeIps);

    //在這裡將已經下線的 data 節點刪除掉、
    //其中已經刪除的 是通過 NodeComparator 內部的getDiff方法 實現的。
    comparator.getRemoved().forEach(slotTableBuilder::removeDataServerSlots);
    return slotTableBuilder;
}

方法引數 SlotTable 是通過 SlotManager 物件獲取到舊的的 SlotTable 物件。currentDataNodeIps 代表當前存活的 dataServer (通過心跳維持和 MetaServer 的連線) 然後傳入 createSlotTableBuilder 方法內部。createSlotTableBuilder 方法內部通過 NodeComparator 物件計算並且包裝了 舊的 "SlotTable 的節點列表" 與 傳入的 currentDataNodeIps 之前的差異值。包括當前 CurrentDataNodeIps 中 新增和刪除的 DataServer。隨之呼叫 SlotTableBuilder 的 init 方法 。執行 SlotTableBuilder 的初始化。

SlotTableBuilder 的 init 原始碼如下。

 public void init(List<String> dataServers) {
    for (int slotId = 0; slotId < slotNums; slotId++) {
      Slot slot = initSlotTable == null ? null : initSlotTable.getSlot(slotId);
      if (slot == null) {
        getOrCreate(slotId);
        continue;
      }
     //1. 重新新建一個 SlotBuilder 將原來的 Slot 裡面的資料拷貝過來
     //2. 拷貝 leader節點
      SlotBuilder slotBuilder =
          new SlotBuilder(slotId, followerNums, slot.getLeader(), slot.getLeaderEpoch());
     //3. 拷貝 follow 節點。
      slotBuilder.addFollower(initSlotTable.getSlot(slotId).getFollowers())
      buildingSlots.put(slotId, slotBuilder);
    }
     //4. 初始化反向查詢索引資料、通過 節點查詢該節點目前管理哪些 slot
    initReverseMap(dataServers);
  }

由上面的程式碼可以看出實際上init做了這麼一件事情: 初始化 SlotBuilder 內部的 slotBuilder物件、並且將原來舊的 SlotTable 的 leader和follow節點全部拷貝過去了。注意在例項化 SlotTableBuilder 的時候傳入了舊的 SlotTable也就是這裡的 initSlotTable 物件。

init 方法最後一步的 initReverseMap 從名稱可以看出構建了一個例項化反向路由表、反向查詢表、從Node節點到Slot的查詢功能、因為在之後的處理當中經常會用到 某一個 data節點負責了那些slot的leader角色、以及哪些slot的follow角色. 所以這裡做了一層索引處理。

再回到 ScheduledSlotArranger 類中 createSlotTableBuilder 方法最後一步,此時 SlotTableBulder 內部已經完成了 舊的 SlotTable 的資料拷貝。

comparator.getRemoved().forEach(slotTableBuilder::removeDataServerSlots);  

上文我們說過 comparator 物件內部儲存了 新的 dataServer 和舊的 'SlotTable 的節點列表' 比較資訊。

所以在新的 dataServer 中已經刪除的節點、我們需要從 SlotTableBuilder 中刪除。內部的刪除邏輯也是迭代所有的 SlotBuilder 比較 leader 和當前節點是否相同、相同則刪除、follow同理。

public void removeDataServerSlots(String dataServer) {
    for (SlotBuilder slotBuilder : buildingSlots.values()) {
      //刪除該 SlotBuilder  follow 節點中的 dataServer
      slotBuilder.removeFollower(dataServer)
      //如果該 SlotBuilder 的 leader 節點是 dataServer ,
      //那麼設定該 slotBuilder 的leader節點為空、需要重新進行分配
      if (dataServer.equals(slotBuilder.getLeader())) {
        slotBuilder.setLeader(null);
      }
    }
    reverseMap.remove(dataServer);
}

總結來說建立 SlotTableBuilder 的過程就是根據舊的 SlotTable 例項化 SlotTableBuilder (內部的 SlotBuilder)、計算 舊的 'SlotTable 的節點列表' 和當前最新的 dataServer的差異值、更新 SlotTableBuilder 內部的 SlotBuilder 相關的 leader 和follow值。

image.png

到這一步實際上已經做完了 SlotTableBuilder 的構建過程。到這裡想想接下來該做什麼呢?
可以想想,如果我們觸發 SlotTable 重新分配的是某一個 dataA 節點下線了,那麼在 slotTableBuilder::removeDataServerSlots 這一步會將我們正在建立的 SlotTableBuilder 中的 dataA 所管理的 Slot 的 leader 或者 follow 刪除掉,那麼該 Slot 的 leader 或者 follow 很可能就會變成空。也就是說該 Slot 沒有 data 節點處理請求。於是我們根據當前 SlotBuilder 中是否有為完成分配的 Slot 來決定是否進行重新分配操作, 是否有未完成分配的Slot程式碼塊如下。

  public boolean hasNoAssignedSlots() {
    for (SlotBuilder slotBuilder : buildingSlots.values()) {
      if (StringUtils.isEmpty(slotBuilder.getLeader())) {
        //當前 Slot的leader節點為空
        return true;
      }
      if (slotBuilder.getFollowerSize() < followerNums) {
        //當前 Slot的follow節點的個數小於配置的 followerNums
        return true;
      }
    }
    return false;
  }

建立完成 SlotTableBuilder 並且有沒有完成分配的 Slot, 執行真正的分配過程,如下圖所示。

image.png

由圖可知分配過程最後委託給 DefaultSlotAssigner ,DefaultSlotAssigner 在構造方法中例項化了 當前正在建立的 SlotTableBuilder /currentDataServers 的檢視/MigrateSlotGroup, 其中 MigrateSlotGroup

內部儲存的是那些缺少 leader 以及 followSlot

public class MigrateSlotGroup {
   //哪些 Slot 缺少 leader
  private final Set<Integer> leaders = Sets.newHashSet();
  //哪些Slot 缺少 follow 以及缺少的個數
  private final Map<Integer, Integer> lackFollowers = Maps.newHashMap();
}

assign 程式碼如下. 程式碼中先分配 缺少leader的 slot、隨後分配缺少 follow 的 slot

public SlotTable assign() {
    BalancePolicy balancePolicy = new NaiveBalancePolicy();
    final int ceilAvg =
        MathUtils.divideCeil(slotTableBuilder.getSlotNums(), currentDataServers.size());
    final int high = balancePolicy.getHighWaterMarkSlotLeaderNums(ceilAvg);
    
    //分配缺少leader的slot
    if (tryAssignLeaderSlots(high)) {
      slotTableBuilder.incrEpoch();
    } 
    //分配缺少 follow 的 slot
    if (assignFollowerSlots()) {
      slotTableBuilder.incrEpoch();
    } 
    return slotTableBuilder.build();
}

leader 節點分配

進入 tryAssignLeaderSlots 方法內部檢視具體分配演算法細節。通過程式碼註釋的方式來解釋具體實現。

private boolean tryAssignLeaderSlots(int highWatermark) {
    //按照 follows 節點的數量 從大到小排序 0比較特殊排在最後面,0 為什麼比較特殊呢、因為無論怎麼分配、
    //最終選擇出來的leader一定不是該slot的follow、因為該slot的follow為空
    //優先安排 follow節點比較少的 Slot
    //其實這點也可以想明白的。這些沒有 leader 的 slot 分配順序肯定是要根據 follow節點越少的優先分配最好
    //以防止這個 follow 也掛了、那麼資料就有可能會丟失了。
    List<Integer> leaders =
        migrateSlotGroup.getLeadersByScore(new FewerFollowerFirstStrategy(slotTableBuilder));
    for (int slotId : leaders) {
      List<String> currentDataNodes = Lists.newArrayList(currentDataServers);
       //選擇 nextLeader 節點演算法?
      String nextLeader =
          Selectors.slotLeaderSelector(highWatermark, slotTableBuilder, slotId)
              .select(currentDataNodes);
      //判斷nextLeader是否是當前slot的follow節點 將follow節點提升為主節點的。 
      boolean nextLeaderWasFollower = isNextLeaderFollowerOfSlot(slotId, nextLeader);
      // 將當前 slot 的 leader 節點用選擇出來的 nextLeader 替換
      slotTableBuilder.replaceLeader(slotId, nextLeader);
      if (nextLeaderWasFollower) {
        //因為當前 Slot 將 follow節點提升為leader節點了、那麼該 Slot 肯定 follows 個數又不夠了、需要再次分配 follow 節點
        migrateSlotGroup.addFollower(slotId);
      }
    }
    return true;
  }

上面分配 leader 程式碼中核心選擇 nextLeader 方法。

 String nextLeader =
          Selectors.slotLeaderSelector(highWatermark, slotTableBuilder, slotId)
              .select(currentDataNodes);

通過 Selectors 選擇一個 合適的 leader節點。

繼續追蹤 DefaultSlotLeaderSelector.select 方法內部。同理我們採用程式碼註釋的方式來解釋具體實現。

public String select(Collection<String> candidates) {
  //candidates: 當前所有的候選節點,也是 tryAssignLeaderSlots 方法傳入的 currentDataServers
  Set<String> currentFollowers = slotTableBuilder.getOrCreate(slotId).getFollowers();
  Collection<String> followerCandidates = Lists.newArrayList(candidates);
  followerCandidates.retainAll(currentFollowers);
  //經過 followerCandidates.retainAll(currentFollowers)) 之後 followerCandidates 
  //僅僅保留 當前 Slot 的 follow 節點
  //並且採取了一個策略是 當前 follow 節點作為其他 Slot 的leader最少的優先、
  //用直白的話來說。
  //當前 follower 越是沒有被當做其他 Slot 的leader節點、那麼
  //證明他就是越 '閒' 的。必然優先考慮選擇它作為leader 節點。
  String leader = new LeastLeaderFirstSelector(slotTableBuilder).select(followerCandidates);
  if (leader != null) {
    DataNodeSlot dataNodeSlot = slotTableBuilder.getDataNodeSlot(leader);
    if (dataNodeSlot.getLeaders().size() < highWaterMark) {
      return leader;
    }
  }
  //從其他的機器中選擇一個,優先選擇充當 leader 的 slot 個數最少的那一個 DataServer
  return new LeastLeaderFirstSelector(slotTableBuilder).select(candidates);
}

通過上面 select 方法原始碼註釋相信可以很容易理解 SOFARegistry 的做法。總結來說,就是首先從 當前 slot 的 follow 節點中找出 leader,因為在此情況下不需要做資料遷移,相當於主節點掛了,提升備份節點為主節點實現高可用。但是具體選擇哪一個,SOFARegistry 採取的策略是
在所有的 follow 節點中找出最 "閒"的那一個,但是如果它所有的 follow 節點作為 leader 節點管理的 Slot 個數大於 highWaterMark,那麼證明該 Slot 的所有 follow 節點都太"忙"了,那麼就會從全部存活的機器中選擇一個 "當作為 leader 節點管理的 Slot個數"最少的那一個,但是這種情況其實有資料同步開銷的。

follow 節點分配

同理通過原始碼註解方式來詳述

  private boolean assignFollowerSlots() {
    //使用 FollowerEmergentScoreJury 排序得分策略表明
    // 某一個 slot 缺少越多 follow、排序越靠前。  
    List<MigrateSlotGroup.FollowerToAssign> followerToAssigns =
        migrateSlotGroup.getFollowersByScore(new FollowerEmergentScoreJury());
    int assignCount = 0;
    for (MigrateSlotGroup.FollowerToAssign followerToAssign : followerToAssigns) {
      // 當前待分配的 slotId
      final int slotId = followerToAssign.getSlotId();
      // 當前 slotId 槽中還有多少待分配的 follow 從節點。依次迭代分配。
      for (int i = 0; i < followerToAssign.getAssigneeNums(); i++) {
        final List<String> candidates = Lists.newArrayList(currentDataServers);
        // 根據上文中的 DataNodeSlot 結構、依據 節點被作為follow 的slot的個數從小到大排序。
        // follows 個數一樣、按照最少作為 leader 節點進行排序。
        // 其實最終目的就是找到最 "閒" 的那一臺機器。
        candidates.sort(Comparators.leastFollowersFirst(slotTableBuilder));
        boolean assigned = false;
        for (String candidate : candidates) {
          DataNodeSlot dataNodeSlot = slotTableBuilder.getDataNodeSlot(candidate);
          //跳過已經是它的 follow 或者 leader 節點的Node節點
          if (dataNodeSlot.containsFollower(slotId) || dataNodeSlot.containsLeader(slotId)) {
            continue;
          }
          //給當前 slotId 新增候選 follow 節點。
          slotTableBuilder.addFollower(slotId, candidate);
          assigned = true;
          assignCount++;
          break;
        }
      }
    }
    return assignCount != 0;
  }

如之前所述、MigrateSlotGroup 儲存了 需要進行重新分配 leader 以及 follow 的 Slot 資訊。演算法的主要步驟如下。

  1. 找到所有沒有足夠 follow 的 Slot 資訊
  2. 根據 缺少 follow 個數越多越優先原則排序
  3. 迭代所有缺少 follow 的 Slot 資訊 這裡是 被 MigrateSlotGroup.FollowerToAssign 包裝
  4. 內部迴圈迭代缺少 follow 大小、新增給該 Slot 所需的 follow
  5. 對候選 dataServer 進行排序、按照 “閒、忙“成都進行排序
  6. 執行新增 follow 節點

到此、我麼已經給缺少 leader 或者 follow 的 Slot 完成了節點分配。

SlotTable 平衡演算法

瞭解完 SlotTable 的變更過程以及演算法之後、相信大家對此有了自己的理解。那麼SlotTable 的平衡過程其實也是類似的。詳情可以參考原始碼com.alipay.sofa.registry.server.Meta.slot.balance.DefaultSlotBalancer。

因為在節點的頻繁上下線過程中、勢必會導致某一些節點的負載(負責的 slot 管理數量)過高、某些節點的負載又很低、這樣需要一種動態平衡機制來保證節點的相對負載均衡。

入口在 DefaultSlotBalancer.balance方法內部

public SlotTable balance() {
    //平衡 leader 節點
    if (balanceLeaderSlots()) {
      LOGGER.info("[balanceLeaderSlots] end");
      slotTableBuilder.incrEpoch();
      return slotTableBuilder.build();
    }
    if (balanceHighFollowerSlots()) {
      LOGGER.info("[balanceHighFollowerSlots] end");
      slotTableBuilder.incrEpoch();
      return slotTableBuilder.build();
    }
    if (balanceLowFollowerSlots()) {
      LOGGER.info("[balanceLowFollowerSlots] end");
      slotTableBuilder.incrEpoch();
      return slotTableBuilder.build();
    }
    // check the low watermark leader, the follower has balanced
    // just upgrade the followers in low data server
    if (balanceLowLeaders()) {
      LOGGER.info("[balanceLowLeaders] end");
      slotTableBuilder.incrEpoch();
      return slotTableBuilder.build();
    }
    return null;
}

由於篇幅限制、這裡只分析 leader 節點平衡過程。如上原始碼中的 balanceLeaderSlots() 其餘過程和 它類似、感興趣的讀者也可以自己查詢原始碼分析。

進入 balanceLeaderSlots 方法內部。

  private boolean balanceLeaderSlots() {
    //這裡就是找到每一個節點 dataServer 作為leader的 slot 個數的最大天花板值----> 
    //容易想到的方案肯定是平均方式、一共有 slotNum 個slot、
    //將這些slot的leader歸屬平均分配給 currentDataServer
    final int leaderCeilAvg = MathUtils.divideCeil(slotNum, currentDataServers.size());
    if (upgradeHighLeaders(leaderCeilAvg)) {
      //如果有替換過 leader、那麼就直接返回、不用進行 migrateHighLeaders 操作
      return true;
    }
    if (migrateHighLeaders(leaderCeilAvg)) {
      //經過上面的 upgradeHighLeaders 操作
      //不能找到 follow 進行遷移、因為所有的follow也都很忙、在 exclude 當中、
      //所以沒法找到一個follow進行遷移。那麼我們嘗試遷移 follow。
      //因為 highLeader 的所有 follower 都是比較忙、所以需要將這些忙的節點進行遷移、期待給這些 highLeader 所負責的 slot 替換一些比較清閒的 follow
      return true;
    }
    return false;
  }

我們重點關注 upgradeHighLeaders 方法、同理採用原始碼註解的方式

 private boolean upgradeHighLeaders(int ceilAvg) {
    //"如果一個節點的leader的slot個數大於閾值、那麼就會用目標slot的follow節點來替換當前leader"  最多移動 maxMove次數
    final int maxMove = balancePolicy.getMaxMoveLeaderSlots();
    //理解來說這塊可以直接將節點的 leader 個數大於 ceilAvg 的 節點用其他節點替換就可以了、為什麼還要再次向上取整呢?
    //主要是防止slotTable出現抖動,所以設定了觸發變更的上下閾值 這裡向上取整、是作為一個不平衡閾值來使用、
    // 就是隻針對於不平衡多少(這個多少可以控制)的進行再平衡處理
    final int threshold = balancePolicy.getHighWaterMarkSlotLeaderNums(ceilAvg);
    int balanced = 0;
    Set<String> notSatisfies = Sets.newHashSet();
    //迴圈執行替換操作、預設執行 maxMove 次
    while (balanced < maxMove) {
      int last = balanced;
      //1. 找到 哪些節點的 leader 個數 超過 threshold 、並對這些節點按照leader 的個數的從大到小排列。
      final List<String> highDataServers = findDataServersLeaderHighWaterMark(threshold);
      if (highDataServers.isEmpty()) {
        break;
      }
      // 沒有任何 follow 節點能用來晉升到 leader 節點
      if (notSatisfies.containsAll(highDataServers)) {
        break;
      }
      //2. 找到可以作為新的leader的 節點,但是不包含已經不能新增任何leader的節點、因為這些節點的leader已經超過閾值了。
      final Set<String> excludes = Sets.newHashSet(highDataServers);
      excludes.addAll(findDataServersLeaderHighWaterMark(threshold - 1));
      for (String highDataServer : highDataServers) {
        if (notSatisfies.contains(highDataServer)) {
          //如果該節點已經在不滿足替換條件佇列中、則不在進行查詢可替換節點操作
          continue;
        }
        //找到可以作為新的leader的 節點,但是不包含已經不能新增任何leader的節點、因為這些節點的leader已經超過閾值了。
        //演算法過程是: 
        //1. 從 highDataServer 所負責的所有 slot 中找到某一個 slot、這個 slot 滿足一個條件就是: 該 slot 的 follow 節點中有一個最閒(也就是 節點的leader的最小)
        //2. 找到這個 slot、我們只需要替換該 slot 的leader為找到的follow
        
        //其實站在巨集觀的角度來說就是將 highDataServer 節點leader 的所有slot的follow節點按照閒忙程度進行排序、
        //找到那個最閒的、然後讓他當leader。這樣就替換了 highDataServer 當leader了
        Tuple<String, Integer> selected = selectFollower4LeaderUpgradeOut(highDataServer, excludes);
        if (selected == null) {
          //沒有找到任何 follow節點用來代替 highDataServer節點、所以該節點不滿足可替換條件、加入到 notSatisfies 不可替換佇列中. 以便於外層迴圈直接過濾。
          notSatisfies.add(highDataServer);
          continue;
        }
        //找到 highDataServer 節點的某一個可替換的 slotId
        final int slotId = selected.o2;
        // 找到 slotId 替換 highDataServer 作為leader 的節點 newLeaderDataServer
        final String newLeaderDataServer = selected.o1; 
        // 用 newLeaderDataServer 替換 slotId 舊的 leader 節點。
        slotTableBuilder.replaceLeader(slotId, newLeaderDataServer); 
        balanced++;
      }
      if (last == balanced) break;
    }
    return balanced != 0;
  }

進入關鍵查詢可替換的 slotId 和新的 leader 節點的過程,同理採用原始碼註解的方式。

  /*
    從 leaderDataServer 所leader的所有slot中、選擇一個可以替換的slotId
    和新的leader來替換leaderDataServer
   */
  private Tuple<String, Integer> selectFollower4LeaderUpgradeOut(
      String leaderDataServer, Set<String> excludes) {
    //獲取當前 leaderDataServer 節點 leader 或者 follow 的slotId 檢視。DataNodeSlot 結構我們上文有說過。
    final DataNodeSlot dataNodeSlot = slotTableBuilder.getDataNodeSlot(leaderDataServer);

    Set<Integer> leaderSlots = dataNodeSlot.getLeaders();
    Map<String, List<Integer>> dataServers2Followers = Maps.newHashMap();
    //1. 從 dataNodeSlot 獲取 leaderDataServer 節點leader的所有slotId: leaderSlots
    for (int slot : leaderSlots) {
      //2. 從slotTableBuilder 中找出當前 slot 的follow
      List<String> followerDataServers = slotTableBuilder.getDataServersOwnsFollower(slot);
      //3. 去掉excludes ,得到候選節點,因為 excludes 肯定不會是新的 leader 節點
      followerDataServers = getCandidateDataServers(excludes, null, followerDataServers);
      //4. 構建 候選節點到 slotId 集合的對映關係。
      for (String followerDataServer : followerDataServers) {
        List<Integer> followerSlots =
            dataServers2Followers.computeIfAbsent(followerDataServer, k -> Lists.newArrayList());
        followerSlots.add(slot);
      }
    }
    if (dataServers2Followers.isEmpty()) {
      //當 leaderDataServer 節點的follow 都是 excludes 中的成員時候、那麼就有可能是空的。
      return null;
    }
    List<String> dataServers = Lists.newArrayList(dataServers2Followers.keySet());
    //按照 候選節點的 leader的 slot 個數升序排序、也就是也就是找到那個最不忙的,感興趣可以檢視 leastLeadersFirst 方法內部實現。
    dataServers.sort(Comparators.leastLeadersFirst(slotTableBuilder));
    final String selectedDataServer = dataServers.get(0);
    List<Integer> followers = dataServers2Followers.get(selectedDataServer);
    return Tuple.of(selectedDataServer, followers.get(0));
  }

至此我們完成了 高負載 leader 節點的替換、在此過程中如果有替換過、那麼直接返回、如果沒有替換過、我們會繼續執行 DefaultSlotBalancer 中的 migrateHighLeaders 操作。因為如果經過 DefaultSlotBalancer 中的 upgradeHighLeaders 操作之後沒有進行過任何leader的替換、那麼證明 高負載的 leader 節點同樣它的 follow 節點也很忙、所以需要做得就是對這些忙的 follow 節點也要進行遷移。我們繼續通過原始碼註釋的方式來檢視具體的過程。

private boolean migrateHighLeaders(int ceilAvg) {
    final int maxMove = balancePolicy.getMaxMoveFollowerSlots();
    final int threshold = balancePolicy.getHighWaterMarkSlotLeaderNums(ceilAvg);

    int balanced = 0;
    while (balanced < maxMove) {
      int last = balanced;
      // 1. find the dataNode which has leaders more than high water mark
      //    and sorted by leaders.num desc
      final List<String> highDataServers = findDataServersLeaderHighWaterMark(threshold);
      if (highDataServers.isEmpty()) {
        return false;
      }
      // 2. find the dataNode which could own a new leader
      // exclude the high
      final Set<String> excludes = Sets.newHashSet(highDataServers);
      // exclude the dataNode which could not add any leader
      excludes.addAll(findDataServersLeaderHighWaterMark(threshold - 1));
      final Set<String> newFollowerDataServers = Sets.newHashSet();
      // only balance highDataServer once at one round, avoid the follower moves multi times
      for (String highDataServer : highDataServers) {
        Triple<String, Integer, String> selected =
            selectFollower4LeaderMigrate(highDataServer, excludes, newFollowerDataServers);
        if (selected == null) {
          continue;
        }
        final String oldFollower = selected.getFirst();
        final int slotId = selected.getMiddle();
        final String newFollower = selected.getLast();
        slotTableBuilder.removeFollower(slotId, oldFollower);
        slotTableBuilder.addFollower(slotId, newFollower);
        newFollowerDataServers.add(newFollower);
        balanced++;
      }
      if (last == balanced) break;
    }
    return balanced != 0;
  }

3. session 和 data 節點如何使用路由表

上文我們 瞭解了 SlotTable 路由表在心跳中從 Meta 節點獲取並且更新到本地中、那麼 session 和 data 節點如何使用路由表呢。首先我們先看看 session 節點如何使用 SlotTable 路由表。 session 節點承擔著客戶端的釋出訂閱請求,並且通過 SlotTable 路由表對 data 節點的資料進行讀寫; session 節點本地 SlotTable 路由表儲存在 SlotTableCacheImpl 。

public final class SlotTableCacheImpl implements SlotTableCache {
  // 不同計算 slot 位置的演算法抽象
  private final SlotFunction slotFunction = SlotFunctionRegistry.getFunc();

  //本地路由表、心跳中從 Meta 節點獲取到。
  private volatile SlotTable slotTable = SlotTable.INIT;
    
    
  //根據 dataInfoId 獲取 slotId
  @Override
  public int slotOf(String dataInfoId) {
    return slotFunction.slotOf(dataInfoId);
  }
}

原始碼中的 SlotFunctionRegistry 註冊了兩種演算法實現。分別是 crc32 和 md5 實現、原始碼如下所示。

public final class SlotFunctionRegistry {
  private static final Map<String, SlotFunction> funcs = Maps.newConcurrentMap();

  static {
    register(Crc32cSlotFunction.INSTANCE);
    register(MD5SlotFunction.INSTANCE);
  }

  public static void register(SlotFunction func) {
    funcs.put(func.name(), func);
  }

  public static SlotFunction getFunc() {
    return funcs.get(SlotConfig.FUNC);
  }
}

隨便選擇某一個演算法、例如 MD5SlotFunction、根據 dataInfoId 計算 slotId 的實現如下。

public final class MD5SlotFunction implements SlotFunction {
  public static final MD5SlotFunction INSTANCE = new MD5SlotFunction();

  private final int maxSlots;
  private final MD5HashFunction md5HashFunction = new MD5HashFunction();

  private MD5SlotFunction() {
    this.maxSlots = SlotConfig.SLOT_NUM;
  }
  //計算 slotId的最底層邏輯。可見也是通過取hash然後對 slot槽個數取餘
  @Override
  public int slotOf(Object o) {
    // make sure >=0
    final int hash = Math.abs(md5HashFunction.hash(o));
    return hash % maxSlots;
  }
}

瞭解了具體根據 DataInfoId 來通過 SlotTable 獲取具體的資料 slotId,我們來看看在 session 節點中何時觸發計算 datInfoId 的 slotId。我們可以想想,一般 session 節點使用來處理客戶端的釋出訂閱請求,那麼當有釋出請求的時候,釋出的資料同時也會向 data 節點寫入釋出的後設資料,那麼肯定需要知道該資料儲存在哪一臺機器上,此時就需要根據 dataInfoId 找到對應的 slotId,進而找到對應的 leader 節點,通過網路通訊工具將釋出請求轉發給該節點處理,session 資料接收發布請求處理 handler 為 PublisherHandler。

image.png

如上面時序圖所示、在 DataNodeServiceImpl 最後的 commitReq 方法中會將釋出請求新增到內部的 BlockingQueue 當中去,DataNodeServiceImpl 內部的 Worker 物件會消費 BlockingQueue 中內部執行真正的資料寫入過程。詳細原始碼請參考 :

private final class Worker implements Runnable {
    final BlockingQueue<Req> queue;
    Worker(BlockingQueue<Req> queue) {
      this.queue = queue;
    }

    @Override
    public void run() {
      for (; ; ) {
          final Req firstReq = queue.poll(200, TimeUnit.MILLISECONDS);
          if (firstReq != null) {
            Map<Integer, LinkedList<Object>> reqs =
                drainReq(queue, sessionServerConfig.getDataNodeMaxBatchSize());
            //因為 slot 的個數有可能大於 work/blockingQueue 的個數、所以
            //並不是一個 slot 對應一個 work、那麼一個blockQueue 中可能存在發往多個slot的資料、這裡
            //有可能一次傳送不完這麼多資料、需要分批傳送、將首先進入佇列的優先傳送了。
            LinkedList<Object> firstBatch = reqs.remove(firstReq.slotId);
            if (firstBatch == null) {
              firstBatch = Lists.newLinkedList();
            }
            firstBatch.addFirst(firstReq.req);
            request(firstReq.slotId, firstBatch);
            for (Map.Entry<Integer, LinkedList<Object>> batch : reqs.entrySet()) {
              request(batch.getKey(), batch.getValue());
            }
          }
        }
     }
 }

private boolean request(BatchRequest batch) {
  final Slot slot = getSlot(batch.getSlotId());
  batch.setSlotTableEpoch(slotTableCache.getEpoch());
  batch.setSlotLeaderEpoch(slot.getLeaderEpoch());
  sendRequest(
      new Request() {
        @Override
        public Object getRequestBody() {
          return batch;
        }

        @Override
        public URL getRequestUrl() {
          //通過 slot 路由表找到對應的 leader data節點,這
          //個路由表是 心跳中從 Meta 節點獲取來的。
          return getUrl(slot);
        }
      });
  return true;
}

藉此原始碼解析的任務讓我仔細的研究了 SlotTable 資料預分片機制,真正瞭解了一個工業級別的資料分片是如何實現的,高可用是如何做的,以及在功能實現中的各種取捨。

因此和大家分享這篇對 SOFARegistry SlotTable 的剖析 ,歡迎大家留言指導。

也有幸參與了 SOFARegistry 的社群會議,在社群前輩們的指導下認真瞭解了具體細節的設計考量,一起討論 SOFARegistry 的未來發展。

歡迎加入,參與 SOFA 社群的原始碼解析

目前 SOFARegistry 原始碼解析任務已發完, Layotto 原始碼解析還有 1 個任務待認領,有興趣的朋友可以試試看。

圖片

Layotto

待認領任務:WebAssembly 相關

https://github.com/mosn/layotto/issues/427

之後也會推出其他專案的原始碼解析活動,敬請期待...

相關文章