SOFAStack(Scalable Open Financial Architecture Stack )是螞蟻金服自主研發的金融級雲原生架構,包含了建構金融級雲原生架構所需的各個元件,是在金融場景裡錘煉出來的最佳實踐。

SOFARegistry 是螞蟻金服開源的具有承載海量服務注冊和訂閱能力的、高可用的服務注冊中心,最早源自于淘寶的初版 ConfigServer,在支付寶/螞蟻金服的業務發展驅動下,近十年間已經演進至第五代。
本文為《剖析 | SOFARegistry 架構》最後一篇,本篇作者404P(花名岩途)。《剖析 | SOFARegistry 架構》系列由 SOFA 團隊和源碼愛好者們出品,項目代号:,文末包含往期系列文章。
GitHub 位址:
https://github.com/sofastack/sofa-registry前言
在微服務架構體系下,服務注冊中心緻力于解決微服務之間服務發現的問題。在服務數量不多的情況下,服務注冊中心叢集中每台機器都儲存着全量的服務資料,但随着螞蟻金服海量服務的出現,單機已無法存儲所有的服務資料,資料分片成為了必然的選擇。資料分片之後,每台機器隻儲存一部分服務資料,節點上下線就容易造成資料波動,很容易影響應用的正常運作。本文通過介紹 SOFARegistry 的分片算法和相關的核心源碼來展示螞蟻金服是如何解決上述問題的。
服務注冊中心簡介
在微服務架構下,一個網際網路應用的服務端背後往往存在大量服務間的互相調用。例如服務 A 在鍊路上依賴于服務 B,那麼在業務發生時,服務 A 需要知道服務 B 的位址,才能完成服務調用。而分布式架構下,每個服務往往都是叢集部署的,叢集中的機器也是經常變化的,是以服務 B 的位址不是固定不變的。如果要保證業務的可靠性,服務調用者則需要感覺被調用服務的位址變化。
圖1 微服務架構下的服務尋址
既然成千上萬的服務調用者都要感覺這樣的變化,那這種感覺能力便下沉成為微服務中一種固定的架構模式:服務注冊中心。
圖2 服務注冊中心
服務注冊中心裡,有服務提供者和服務消費者兩種重要的角色,服務調用方是消費者,服務被調方是提供者。對于同一台機器,往往兼具兩者角色,既被其它服務調用,也調用其它服務。服務提供者将自身提供的服務資訊釋出到服務注冊中心,服務消費者通過訂閱的方式感覺所依賴服務的資訊是否發生變化。
SOFARegistry 總體架構
SOFARegistry 的架構中包括4種角色:Client、Session、Data、Meta,如圖3所示:
圖3 SOFARegistry 總體架構
- Client 層
應用伺服器叢集。Client 層是應用層,每個應用系統通過依賴注冊中心相關的用戶端 jar 包,通過程式設計方式來使用服務注冊中心的服務釋出和服務訂閱能力。
- Session 層
Session 伺服器叢集。顧名思義,Session 層是會話層,通過長連接配接和 Client 層的應用伺服器保持通訊,負責接收 Client 的服務釋出和服務訂閱請求。該層隻在記憶體中儲存各個服務的釋出訂閱關系,對于具體的服務資訊,隻在 Client 層和 Data 層之間透傳轉發。Session 層是無狀态的,可以随着 Client 層應用規模的增長而擴容。
- Data 層
資料伺服器叢集。Data 層通過分片存儲的方式儲存着所用應用的服務注冊資料。資料按照 dataInfoId(每一份服務資料的唯一辨別)進行一緻性 Hash 分片,多副本備份,保證資料的高可用。下文的重點也在于随着資料規模的增長,Data 層如何在不影響業務的前提下實作平滑的擴縮容。
- Meta 層
中繼資料伺服器叢集。這個叢集管轄的範圍是 Session 伺服器叢集和 Data 伺服器叢集的伺服器資訊,其角色就相當于 SOFARegistry 架構内部的服務注冊中心,隻不過 SOFARegistry 作為服務注冊中心是服務于廣大應用服務層,而 Meta 叢集是服務于 SOFARegistry 内部的 Session 叢集和 Data 叢集,Meta 層能夠感覺到 Session 節點和 Data 節點的變化,并通知叢集的其它節點。
SOFARegistry 如何突破單機存儲瓶頸
在螞蟻金服的業務規模下,單台伺服器已經無法存儲所有的服務注冊資料,SOFARegistry 采用了資料分片的方案,每台機器隻儲存一部分資料,同時每台機器有多副本備份,這樣理論上可以無限擴容。根據不同的資料路由方式,常見的資料分片主要分為兩大類:範圍分片和 Hash(哈希)分片。
圖4 資料分片
- 範圍分片
每一個資料分片負責存儲某一鍵值區間範圍的值。例如按照時間段進行分區,每個小時的 Key 放在對應的節點上。區間範圍分片的優勢在于資料分片具有連續性,可以實作區間範圍查詢,但是缺點在于沒有對資料進行随機打散,容易存在熱點資料問題。
- Hash (哈希)分片
Hash 分片則是通過特定的 Hash 函數将資料随機均勻地分散在各個節點中,不支援範圍查詢,隻支援點查詢,即根據某個資料的 Key 擷取資料的内容。業界大多 KV(Key-Value)存儲系統都支援這種方式,包括 cassandra、dynamo、membase 等。業界常見的 Hash 分片算法有哈希取模法、一緻性哈希法和虛拟桶法。
哈希取模
哈希取模的 Hash 函數如下:
H(Key)=hash(key)mod K;
這是一個 key-machine 的函數。key 是資料主鍵,K 是實體機數量,通過資料的 key 能夠直接路由到實體機器。當 K 發生變化時,會影響全體資料分布。所有節點上的資料會被重新分布,這個過程是難以在系統無感覺的情況下平滑完成的。
圖5 哈希取模
一緻性哈希
分布式哈希表(DHT)是 P2P 網絡和分布式存儲中一項常見的技術,是哈希表的分布式擴充,即在每台機器存儲部分資料的前提下,如何通過哈希的方式來對資料進行讀寫路由。其核心在于每個節點不僅隻儲存一部分資料,而且也隻維護一部分路由,進而實作 P2P 網絡節點去中心化的分布式尋址和分布式存儲。DHT 是一個技術概念,其中業界最常見的一種實作方式就是一緻性哈希的 Chord 算法實作。
- 哈希空間
一緻性哈希中的哈希空間是一個資料和節點共用的一個邏輯環形空間,資料和機器通過各自的 Hash 算法得出各自在哈希空間的位置。
圖6 資料項和資料節點共用哈希空間
圖7是一個二進制長度為5的哈希空間,該空間可以表達的數值範圍是0~31(2^5),是一個首尾相接的環狀序列。環上的大圈表示不同的機器節點(一般是虛拟節點),用 $$Ni$$ 來表示,$$i$$ 代表着節點在哈希空間的位置。例如,某個節點根據 IP 位址和端口号進行哈希計算後得出的值是7,那麼 N7 則代表則該節點在哈希空間中的位置。由于每個實體機的配置不一樣,通常配置高的實體節點會虛拟成環上的多個節點。
圖7 長度為5的哈希空間
環上的節點把哈希空間分成多個區間,每個節點負責存儲其中一個區間的資料。例如 N14 節點負責存儲 Hash 值為8~14範圍内的資料,N7 節點負責存儲 Hash 值為31、0~7區間的資料。環上的小圈表示實際要存儲的一項資料,當一項資料通過 Hash 計算出其在哈希環中的位置後,會在環中順時針找到離其最近的節點,該項資料将會儲存在該節點上。例如,一項資料通過 Hash 計算出值為16,那麼應該存在 N18 節點上。通過上述方式,就可以将資料分布式存儲在叢集的不同節點,實作資料分片的功能。
- 節點下線
如圖8所示,節點 N18 出現故障被移除了,那麼之前 N18 節點負責的 Hash 環區間,則被順時針移到 N23 節點,N23 節點存儲的區間由19~23擴充為15~23。N18 節點下線後,Hash 值為16的資料項将會儲存在 N23 節點上。
圖8 一緻性哈希環中節點下線
- 節點上線
如圖9所示,如果叢集中上線一個新節點,其 IP 和端口進行 Hash 後的值為17,那麼其節點名為 N17。那麼 N17 節點所負責的哈希環區間為15~17,N23 節點負責的哈希區間縮小為18~23。N17 節點上線後,Hash 值為16的資料項将會儲存在 N17 節點上。
圖9 一緻性哈希環中節點上線
當節點動态變化時,一緻性哈希仍能夠保持資料的均衡性,同時也避免了全局資料的重新哈希和資料同步。但是,發生變化的兩個相鄰節點所負責的資料分布範圍依舊是會發生變化的,這對資料同步帶來了不便。資料同步一般是通過記錄檔來實作的,而一緻性雜湊演算法的記錄檔往往和資料分布相關聯,在資料分布範圍不穩定的情況下,記錄檔的位置也會随着機器動态上下線而發生變化,在這種場景下難以實作資料的精準同步。例如,上圖中 Hash 環有0~31個取值,假如日志檔案按照這種哈希值來命名的話,那麼 data-16.log 這個檔案日志最初是在 N18 節點,N18 節點下線後,N23 節點也有 data-16.log 了,N17 節點上線後,N17 節點也有 data-16.log 了。是以,需要有一種機制能夠保證記錄檔的位置不會因為節點動态變化而受到影響。
虛拟桶預分片
虛拟桶則是将 key-node 映射進行了分解,在資料項和節點之間引入了虛拟桶這一層。如圖所示,資料路由分為兩步,先通過 key 做 Hash 運算計算出資料項應所對應的 slot,然後再通過 slot 和節點之間的映射關系得出該資料項應該存在哪個節點上。其中 slot 數量是固定的,key - slot 之間的哈希映射關系不會因為節點的動态變化而發生改變,資料的記錄檔也和slot相對應,進而保證了資料同步的可行性。
圖10 虛拟桶預分片機制
路由表中存儲着所有節點和所有 slot 之間的映射關系,并盡量確定 slot 和節點之間的映射是均衡的。這樣,在節點動态變化的時候,隻需要修改路由表中 slot 和動态節點之間的關系即可,既保證了彈性擴縮容,也降低了資料同步的難度。
SOFARegistry 的分片選擇
通過上述一緻性哈希分片和虛拟桶分片的對比,我們可以總結一下它們之間的差異性:一緻性哈希比較适合分布式緩存類的場景,這種場景重在解決資料均衡分布、避免資料熱點和緩存加速的問題,不保證資料的高可靠,例如 Memcached;而虛拟桶則比較适合通過資料多副本來保證資料高可靠的場景,例如 Tair、Cassandra。
顯然,SOFARegistry 比較适合采用虛拟桶的方式,因為服務注冊中心對于資料具有高可靠性要求。但由于曆史原因,SOFARegistry 最早選擇了一緻性哈希分片,是以同樣遇到了資料分布不固定帶來的資料同步難題。我們如何解決的呢?我們通過在 DataServer 記憶體中以 dataInfoId 的粒度記錄記錄檔,并且在 DataServer 之間也是以 dataInfoId 的粒度去做資料同步(一個服務就由一個 dataInfoId 唯辨別)。其實這種日志記錄的思想和虛拟桶是一緻的,隻是每個 datainfoId 就相當于一個 slot 了,這是一種因曆史原因而采取的妥協方案。在服務注冊中心的場景下,datainfoId 往往對應着一個釋出的服務,是以總量還是比較有限的,以螞蟻金服目前的規模,每台 DataServer 中承載的 dataInfoId 數量也僅在數萬的級别,勉強實作了 dataInfoId 作為 slot 的資料多副本同步方案。
DataServer 擴縮容相關源碼
注:本次源碼解讀基于 registry-server-data 的5.3.0版本。
DataServer 的核心啟動類是 DataServerBootstrap,該類主要包含了三類元件:節點間的 bolt 通信元件、JVM 内部的事件通信元件、定時器元件。
圖11 DataServerBootstrap 的核心元件
- 外部節點通信元件:在該類中有3個 Server 通信對象,用于和其它外部節點進行通信。其中 httpServer 主要提供一系列 http 接口,用于 dashboard 管理、資料查詢等;dataSyncServer 主要是處理一些資料同步相關的服務;dataServer 則負責資料相關服務;從其注冊的 handler 來看,dataSyncServer 和 dataSever 的職責有部分重疊;
- JVM 内部通信元件:DataServer 内部邏輯主要是通過事件驅動機制來實作的,圖12列舉了部分事件在事件中心的互動流程,從圖中可以看到,一個事件往往會有多個投遞源,非常适合用 EventCenter 來解耦事件投遞和事件處理之間的邏輯;
- 定時器元件:例如定時檢測節點資訊、定時檢測資料版本資訊;
圖12 DataServer 中的核心事件流轉
DataServer 節點擴容
假設随着業務規模的增長,Data 叢集需要擴容新的 Data 節點。如圖13,Data4 是新增的 Data 節點,當新節點 Data4 啟動時,Data4 處于初始化狀态,在該狀态下,對于 Data4 的資料寫操作被禁止,資料讀操作會轉發到其它節點,同時,存量節點中屬于新節點的資料将會被新節點和其副本節點拉取過來。
圖13 DataServer 節點擴容場景
- 轉發讀操作
在資料未同步完成之前,所有對新節點的讀資料操作,将轉發到擁有該資料分片的資料節點。
查詢服務資料處理器 GetDataHandler
public Object doHandle(Channel channel, GetDataRequest request) {
String dataInfoId = request.getDataInfoId();
if (forwardService.needForward()) {
// ... 如果不是WORKING狀态,則需要轉發讀操作
return forwardService.forwardRequest(dataInfoId, request);
}
}
轉發服務 ForwardServiceImpl
public Object forwardRequest(String dataInfoId, Object request) throws RemotingException {
// 1. get store nodes
List<DataServerNode> dataServerNodes = DataServerNodeFactory
.computeDataServerNodes(dataServerConfig.getLocalDataCenter(), dataInfoId,
dataServerConfig.getStoreNodes());
// 2. find nex node
boolean next = false;
String localIp = NetUtil.getLocalAddress().getHostAddress();
DataServerNode nextNode = null;
for (DataServerNode dataServerNode : dataServerNodes) {
if (next) {
nextNode = dataServerNode;
break;
}
if (null != localIp && localIp.equals(dataServerNode.getIp())) {
next = true;
}
}
// 3. invoke and return result
}
轉發讀操作時,分為3個步驟:首先,根據目前機器所在的資料中心(每個資料中心都有一個哈希空間)、 dataInfoId 和資料備份數量(預設是3)來計算要讀取的資料項所在的節點清單;其次,從這些節點清單中找出一個 IP 和本機不一緻的節點作為轉發目标節點;最後,将讀請求轉發至目标節點,并将讀取的資料項傳回給 session 節點。
圖14 DataServer 節點擴容時的讀請求
- 禁止寫操作
在資料未同步完成之前,禁止對新節點的寫資料操作,防止在資料同步過程中出現新的資料不一緻情況。
釋出服務處理器 PublishDataHandler
public Object doHandle(Channel channel, PublishDataRequest request) {
if (forwardService.needForward()) {
// ...
response.setSuccess(false);
response.setMessage("Request refused, Server status is not working");
return response;
}
}
圖15 DataServer 節點擴容時的寫請求
DataServer 節點縮容
以圖16為例,資料項 Key 12 的讀寫請求均落在 N14 節點上,當 N14 節點接收到寫請求後,會同時将資料同步給後繼的節點 N17、N23(假設此時的副本數是 3)。當 N14 節點下線,MetaServer 感覺到與 N14 的連接配接失效後,會剔除 N14 節點,同時向各節點推送 NodeChangeResult 請求,各資料節點收到該請求後,會更新本地的節點資訊,并重新計算環空間。在哈希空間重新重新整理之後,資料項 Key 12 的讀取請求均落在 N17 節點上,由于 N17 節點上有 N14 節點上的所有資料,是以此時的切換是平滑穩定的。
圖16 DataServer 節點縮容時的平滑切換
節點變更時的資料同步
MetaServer 會通過網絡連接配接感覺到新節點上線或者下線,所有的 DataServer 中運作着一個定時重新整理連接配接的任務 ConnectionRefreshTask,該任務定時去輪詢 MetaServer,擷取資料節點的資訊。需要注意的是,除了 DataServer 主動去 MetaServer 拉取節點資訊外,MetaServer 也會主動發送 NodeChangeResult 請求到各個節點,通知節點資訊發生變化,推拉擷取資訊的最終效果是一緻的。
當輪詢資訊傳回資料節點有變化時,會向 EventCenter 投遞一個 DataServerChangeEvent 事件,在該事件的處理器中,如果判斷出是目前機房節點資訊有變化,則會投遞新的事件 LocalDataServerChangeEvent,該事件的處理器 LocalDataServerChangeEventHandler 中會判斷目前節點是否為新加入的節點,如果是新節點則會向其它節點發送 NotifyOnlineRequest 請求,如圖17所示:
圖17 DataServer 節點上線時新節點的邏輯
同機房資料節點變更事件處理器 LocalDataServerChangeEventHandler
public class LocalDataServerChangeEventHandler {
// 同一叢集資料同步器
private class LocalClusterDataSyncer implements Runnable {
public void run() {
if (LocalServerStatusEnum.WORKING == dataNodeStatus.getStatus()) {
//if local server is working, compare sync data
notifyToFetch(event, changeVersion);
} else {
dataServerCache.checkAndUpdateStatus(changeVersion);
//if local server is not working, notify others that i am newer
notifyOnline(changeVersion);;
}
}
}
}
圖17展示的是新加入節點收到節點變更消息的處理邏輯,如果是線上已經運作的節點收到節點變更的消息,前面的處理流程都相同,不同之處在于 LocalDataServerChangeEventHandler 中會根據 Hash 環計算出變更節點(擴容場景下,變更節點是新節點,縮容場景下,變更節點是下線節點在 Hash 環中的後繼節點)所負責的資料分片範圍和其備份節點。目前節點周遊自身記憶體中的資料項,過濾出屬于變更節點的分片範圍的資料項,然後向變更節點和其備份節點發送 NotifyFetchDatumRequest 請求, 變更節點和其備份節點收到該請求後,其處理器會向發送者同步資料(NotifyFetchDatumHandler.fetchDatum),如圖18所示。
圖18 DataServer 節點變更時已存節點的邏輯
總結
SOFARegistry 為了解決海量服務注冊和訂閱的場景,在 DataServer 叢集中采用了一緻性 Hash 算法進行資料分片,突破了單機存儲的瓶頸,理論上提供了無限擴充的可能性。同時 SOFARegistry 為了實作資料的高可用,在 DataServer 記憶體中以 dataInfoId 的粒度記錄服務資料,并在 DataServer 之間通過 dataInfoId 的緯度進行資料同步,保障了資料一緻性的同時也實作了 DataServer 平滑地擴縮容。