nacos原理三-註冊中心原理&原始碼啟動.md

QiaoZhi發表於2024-06-25

1. nacos 服務端原始碼啟動

資源資訊:

作業系統:mac
JDK: 8
nacos: 1.1.4 (2.2.1 版本需要protobuf, 外掛比較麻煩就放棄了)
  1. 下載專案

選擇 1.1.4 版本

  1. mvn 編譯所有專案
  2. 建立資料庫:nacos/distribution/conf/nacos-mysql.sql
  3. 修改資料庫連線資訊:console/src/main/resources/application.properties
# mysql datasource
spring.datasource.platform=mysql
db.num=1
db.url.0=jdbc:mysql://127.0.0.1:3306/nacos
db.user=root
db.password=
  1. 修改主類設定單機啟動:

console/src/main/java/com/alibaba/nacos/Nacos.java

System.setProperty("nacos.standalone", "true");

2. 關於nacos 簡單理解

1. 資料儲存

nacos 簡單分為註冊中心、配置中心。

配置中心相關資料會落庫(內建derby,支援切換為mysql, 策略模式切換), 註冊中心的資料不會落庫。

2. 註冊中心模組叢集間資料同步

記憶體儲存,當前記憶體增加之後,同步給叢集中的其他兄弟節點。

// 大概兩種實現:distro(非同步傳送同步)、raft(同步傳送,需要半數節點進行ACK)

  1. 平等節點:一個名為Distro的一致性協議演算法, 當前註冊完之後同步給其他兄弟節點

com.alibaba.nacos.naming.consistency.ephemeral.distro.DistroConsistencyServiceImpl#put

資料分割槽:
	Distro演算法將資料分割成多個塊。
	每個Nacos伺服器節點負責儲存和管理一個特定的資料塊。
責任分配:
	每個資料塊的生成、刪除和同步操作都由負責該塊的伺服器執行。
	這意味著每個Nacos伺服器僅處理總服務資料子集的寫操作,降低了單個節點的壓力。
資料同步:
	所有的Nacos伺服器同時接收其他伺服器的資料同步。
	透過這種方式,隨著時間的推移,每個伺服器都將最終擁有完整的資料集。
	這確保了即使在分散式環境中,資料的一致性和可用性。

這種設計有助於提高系統的可擴充套件性和容錯性,因為任何單個節點的故障都不會導致整個資料集的丟失,而且可以並行處理資料操作以提高效能。
  1. 叫做Raft 協議的一致性: 從叢集中選一個leader 節點,節點就分為主節點和follower節點

com.alibaba.nacos.naming.consistency.persistent.raft.RaftConsistencyServiceImpl#put

在Raft演算法中,只有Leader才擁有資料處理和資訊分發的權利。因此當服務啟動時,如果註冊中心指定為Follower節點,則步驟如下:

1、Follower 會自動將註冊心跳包轉給 Leader 節點;
2、Leader 節點完成實質的註冊登記工作;
3、完成註冊後向其他 Follower 節點發起“同步註冊日誌”的指令;
4、所有可用的 Follower 在收到指令後進行“ack應答”,通知 Leader 訊息已收到;
5、當 Leader 接收過半數 Follower 節點的 “ack 應答”後,返回給微服務“註冊成功”的響應資訊。
此外,如果某個Follower節點無ack反饋,Leader也會不斷重複傳送,直到所有Follower節點的狀態與Leader同步為止。

3. 鑑權

使用過濾器進行前置校驗,包括如果UA包含Nacos-Server 直接放行,都是 com.alibaba.nacos.core.auth.AuthFilter#doFilter 處理的。

鑑權方式: 簡單的基於登入狀態+許可權的校驗

轉交給鑑權模組com.alibaba.nacos.plugin.auth.impl.NacosAuthPluginService

  1. 校驗賬號密碼合法性:判斷傳的賬號密碼是否合法, jwt 生成token, 校驗token 合法性
  2. 校驗許可權: 判斷使用者是否有對應的許可權碼
  3. 如果是python、sdk,請求的引數會攜帶賬號密碼。 如果是java sdk 會呼叫自己的登入介面獲取到token,之後每次訪問請求引數攜帶token。

3. Nacos-sdk-python服務註冊相關API

​ 服務註冊相關的API 主要涉及三個,服務註冊、心跳、獲取服務。

​ 如果自己實現註冊中心也是重點實現這三個介面即可。 對例項的負載、快取等是在SDK 做的。(如果需要鑑權,還需要實現一個登入介面,登入之後生成token 返回給sdk, sdk 每次請求會在引數攜帶token)

  1. 註冊
uri: http://localhost:8848/nacos/v1/ns/instance?username=XXX&password=lbg-nacosxxx'
method: POST
data:b'ip=127.0.0.1&port=8848&serviceName=qlq_cus&weight=1.0&enable=True&healthy=True&clusterName=None&ephemeral=True&groupName=DEFAULT_GROUP&metadata=%7B%22token%22%3A+%2220e05776c4b810ac9557c7ce081dfe32%22%7D'

對應java 介面:
com.alibaba.nacos.naming.controllers.InstanceController#register
  1. 心跳:
uri: http://127.0.0.1:8848/nacos/v1/ns/instance/beat?serviceName=qlq_cus&beat=%7B%22serviceName%22%3A+%22qlq_cus%22%2C+%22ip%22%3A+%22127.0.0.1%22%2C+%22port%22%3A+%228848%22%2C+%22weight%22%3A+1.0%2C+%22ephemeral%22%3A+true%7D&groupName=DEFAULT_GROUP&username=XXX&password=lbg-nacosxxx
	解碼後:
http://127.0.0.1:8848/nacos/v1/ns/instance/beat?serviceName=qlq_cus&beat={"serviceName": "qlq_cus", "ip": "127.0.0.1", "port": "8848", "weight": 1.0, "ephemeral": true}&groupName=DEFAULT_GROUP&username=XXX&password=lbg-nacosxxx
method: PUT

對應java 介面:
com.alibaba.nacos.naming.controllers.InstanceController#beat
  1. 獲取服務:
uri: 'http://localhost:8848/nacos/v1/ns/instance/list?serviceName=qlq_cus&healthyOnly=False&namespaceId=public&groupName=DEFAULT_GROUP'
method: GET

對應java 介面:
com.alibaba.nacos.naming.controllers.InstanceController#list

4. nacos 資料同步原理-CP|AP

​ nacos 有AP\CP, 預設是AP,也就是預設走distro 協議,可以透過配置設定為走Raft 協議來滿足CP 原則。

1. distro 協議 -AP (預設)

​ 核心參考: com.alibaba.nacos.core.distributed.distro.DistroProtocol

​ 自研的一致性協議,主要用於在叢集節點之間實現資料的快速同步和一致性保證。 思想是實現AP,節點分資料處理,也就是一個節點處理部分資料。 處理完之後透過非同步任務,同步到其他節點,趨向於最終一致性。

思路:讀請求所有節點都可以處理;寫請求是根據service_name 或者 例項資訊(ip:port) 進行分片,分到對應的節點進行處理,節點處理完非同步通知其他節點進行同步。

1. com.alibaba.nacos.naming.web.DistroFilter#doFilter
(1). 判斷是否能處理響應: 根據服務名稱或者ip加埠獲取服務下標
int index = distroHash(responsibleTag) % servers.size();
return servers.get(index);

private int distroHash(String responsibleTag) {
	return Math.abs(responsibleTag.hashCode() % Integer.MAX_VALUE);
}
(2). 如果是當前服務自己,放行到後面的處理過程
(3). 否則拿到請求引數和請求頭,http 呼叫到指定的機器進行處理
2. com.alibaba.nacos.naming.core.InstanceOperatorServiceImpl#registerInstance 處理註冊邏輯
(1). 內部處理相關的註冊邏輯
(2). 非同步呼叫其他兄弟節點進行同步資料:com.alibaba.nacos.core.distributed.distro.DistroProtocol#sync
    public void sync(DistroKey distroKey, DataOperation action, long delay) {
        for (Member each : memberManager.allMembersWithoutSelf()) {
            syncToTarget(distroKey, action, each.getAddress(), delay);
        }
    }

大致過程:

  1. 初始化: 節點啟動時初始化distro 相關元件,包含資料同步處理器以及服務監聽器。
  2. 全量同步: 新加入叢集的節點會請求全量資料,其他節點傳送全量資料; 新節點收到全量資料後,進行校驗和處理
  3. 增量同步:全量同步完成後,後續的同步是基於增量同步,只同步新產生的資料。 每個節點定期向其他節點傳送增量請求,同步到本地進行處理
  4. 心跳與確認:節點之前定時傳送心跳,以檢測對方的存活狀態

2. Raft 協議 - CP

核心參考: com.alibaba.nacos.naming.consistency.persistent.raft.RaftCore

思想: 有一個主節點,所有的寫請求打到主節點。主節點然後同步阻塞將資料同步給其他節點,如果半數以上成功,就代表寫入成功; 否則寫入失敗會丟擲非法狀態異常,可能就需要排查nacos 叢集是否正常。

核心思想:

  1. 領導選舉:

(1). Raft 系統中的節點在任何時候都處於三種狀態之一:領導者(Leader)、跟隨者(Follower)或候選者(Candidate)。
(2). 當系統啟動或領導者失效時,節點會轉換為候選者狀態併發起選舉,透過請求投票(RequestVote) RPC 向其他節點尋求支援。
(3). 基於任期(Term)的概念,每個選舉都有一個獨一無二的任期號,確保過時的投票無效,防止選票分裂。
(4). 獲得大多數節點支援的候選者將成為新的領導者,負責處理客戶端請求和管理日誌複製
簡單理解:

  • 每個節點有一個隨機等等時間,當叢集暫未選出主節點或者沒收到主節點心跳,且自己的隨機等待時間到期之後,把自己的角色設為CANDIDATE、然後給自己投一票,然後向兄弟節點收集投票結果,假設B先到期 (com.alibaba.nacos.naming.consistency.persistent.raft.RaftCore.MasterElection#sendVote);
  • 兄弟節點A收到後和自己的票數term 比較,如果小於A當前的票數,那A投給A自己; 否則,A把自己的角色改為FOLLOWER 從節點,然後把自己的票數terms 設為和B節點的一樣、重置自己的等待時間(com.alibaba.nacos.naming.controllers.RaftController#vote)
  • B每次收集完結果之後,判斷票數最多且超過半數,那麼就可以設為leader 主節點, 這是B已經知道自己是主節點
  • 主節點每次心跳會將自己的資訊同步給兄弟節點(只有主節點會發心跳給其他節點,其他節點記錄主節點等資訊-com.alibaba.nacos.naming.consistency.persistent.raft.RaftCore.HeartBeat#sendBeat)。
  1. 資料同步
    (1). 所有的寫請求會到達leader 節點

(2). leader 同步將資料發給其他兄弟節點,超過半數回覆正常才會認為寫入成功; 否則會丟擲非法狀態異常,這時候可能需要排查nacos 叢集狀態

參考: com.alibaba.nacos.naming.consistency.persistent.raft.RaftCore#signalPublish

    public void signalPublish(String key, Record value) throws Exception {
        if (stopWork) {
            throw new IllegalStateException("old raft protocol already stop work");
        }
      	// 不是主節點,就轉發到主節點
        if (!isLeader()) {
            ObjectNode params = JacksonUtils.createEmptyJsonNode();
            params.put("key", key);
            params.replace("value", JacksonUtils.transferToJsonNode(value));
            Map<String, String> parameters = new HashMap<>(1);
            parameters.put("key", key);
            
            final RaftPeer leader = getLeader();
            
            raftProxy.proxyPostLarge(leader.ip, API_PUB, params.toString(), parameters);
            return;
        }
        
        OPERATE_LOCK.lock();
        try {
            final long start = System.currentTimeMillis();
            final Datum datum = new Datum();
            datum.key = key;
            datum.value = value;
            if (getDatum(key) == null) {
                datum.timestamp.set(1L);
            } else {
                datum.timestamp.set(getDatum(key).timestamp.incrementAndGet());
            }
            
            ObjectNode json = JacksonUtils.createEmptyJsonNode();
            json.replace("datum", JacksonUtils.transferToJsonNode(datum));
            json.replace("source", JacksonUtils.transferToJsonNode(peers.local()));
            
            onPublish(datum, peers.local());
            
            final String content = json.toString();
            
          	// 使用閉鎖,等待一般以上節點回覆成功
            final CountDownLatch latch = new CountDownLatch(peers.majorityCount());
            for (final String server : peers.allServersIncludeMyself()) {
                if (isLeader(server)) {
                    latch.countDown();
                    continue;
                }
                final String url = buildUrl(server, API_ON_PUB);
                HttpClient.asyncHttpPostLarge(url, Arrays.asList("key", key), content, new Callback<String>() {
                    @Override
                    public void onReceive(RestResult<String> result) {
                        if (!result.ok()) {
                            Loggers.RAFT
                                    .warn("[RAFT] failed to publish data to peer, datumId={}, peer={}, http code={}",
                                            datum.key, server, result.getCode());
                            return;
                        }
                        latch.countDown();
                    }
                    
                    @Override
                    public void onError(Throwable throwable) {
                        Loggers.RAFT.error("[RAFT] failed to publish data to peer", throwable);
                    }
                    
                    @Override
                    public void onCancel() {
                    
                    }
                });
                
            }
            
          	// 超時就丟擲非法狀態異常
            if (!latch.await(UtilsAndCommons.RAFT_PUBLISH_TIMEOUT, TimeUnit.MILLISECONDS)) {
                // only majority servers return success can we consider this update success
                Loggers.RAFT.error("data publish failed, caused failed to notify majority, key={}", key);
                throw new IllegalStateException("data publish failed, caused failed to notify majority, key=" + key);
            }
            
            long end = System.currentTimeMillis();
            Loggers.RAFT.info("signalPublish cost {} ms, key: {}", (end - start), key);
        } finally {
            OPERATE_LOCK.unlock();
        }
    }

com.alibaba.nacos.naming.consistency.persistent.raft.RaftPeerSet#majorityCount

    public int majorityCount() {
        return peers.size() / 2 + 1;
    }

5. 實現自己的註冊中心需要實現的介面

1. 關於儲存

1、記憶體儲存:可以選擇資料儲存到記憶體中,節點資料儲存到記憶體,資料同步可以參考nacos 的distro 協議。

2、持久化:儲存到redis,比較簡單。 可以選擇redis 的hash 結構,key是服務名稱、hash內部的key 是instanceId 例項ID,value 是例項資訊(包括上次心跳時間等)。

2. 關於介面

參考nacos,如果實現一個自己的註冊中心核心的介面有三個:
1、服務註冊:生成服務資訊和例項資訊,儲存到db
2、心跳: 續期
3、獲取服務: 根據服務獲取所有例項資訊

如果有許可權相關,還需要一個登入介面

參考:

nacos github: https://github.com/alibaba/nacos

nacos-sdk-python: https://github.com/nacos-group/nacos-sdk-python

相關文章