1. nacos 服務端原始碼啟動
資源資訊:
作業系統:mac
JDK: 8
nacos: 1.1.4 (2.2.1 版本需要protobuf, 外掛比較麻煩就放棄了)
- 下載專案
選擇 1.1.4 版本
- mvn 編譯所有專案
- 建立資料庫:nacos/distribution/conf/nacos-mysql.sql
- 修改資料庫連線資訊: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=
- 修改主類設定單機啟動:
console/src/main/java/com/alibaba/nacos/Nacos.java
System.setProperty("nacos.standalone", "true");
2. 關於nacos 簡單理解
1. 資料儲存
nacos 簡單分為註冊中心、配置中心。
配置中心相關資料會落庫(內建derby,支援切換為mysql, 策略模式切換), 註冊中心的資料不會落庫。
2. 註冊中心模組叢集間資料同步
記憶體儲存,當前記憶體增加之後,同步給叢集中的其他兄弟節點。
// 大概兩種實現:distro(非同步傳送同步)、raft(同步傳送,需要半數節點進行ACK)
- 平等節點:一個名為Distro的一致性協議演算法, 當前註冊完之後同步給其他兄弟節點
com.alibaba.nacos.naming.consistency.ephemeral.distro.DistroConsistencyServiceImpl#put
資料分割槽:
Distro演算法將資料分割成多個塊。
每個Nacos伺服器節點負責儲存和管理一個特定的資料塊。
責任分配:
每個資料塊的生成、刪除和同步操作都由負責該塊的伺服器執行。
這意味著每個Nacos伺服器僅處理總服務資料子集的寫操作,降低了單個節點的壓力。
資料同步:
所有的Nacos伺服器同時接收其他伺服器的資料同步。
透過這種方式,隨著時間的推移,每個伺服器都將最終擁有完整的資料集。
這確保了即使在分散式環境中,資料的一致性和可用性。
這種設計有助於提高系統的可擴充套件性和容錯性,因為任何單個節點的故障都不會導致整個資料集的丟失,而且可以並行處理資料操作以提高效能。
- 叫做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
- 校驗賬號密碼合法性:判斷傳的賬號密碼是否合法, jwt 生成token, 校驗token 合法性
- 校驗許可權: 判斷使用者是否有對應的許可權碼
- 如果是python、sdk,請求的引數會攜帶賬號密碼。 如果是java sdk 會呼叫自己的登入介面獲取到token,之後每次訪問請求引數攜帶token。
3. Nacos-sdk-python服務註冊相關API
服務註冊相關的API 主要涉及三個,服務註冊、心跳、獲取服務。
如果自己實現註冊中心也是重點實現這三個介面即可。 對例項的負載、快取等是在SDK 做的。(如果需要鑑權,還需要實現一個登入介面,登入之後生成token 返回給sdk, sdk 每次請求會在引數攜帶token)
- 註冊
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
- 心跳:
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
- 獲取服務:
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);
}
}
大致過程:
- 初始化: 節點啟動時初始化distro 相關元件,包含資料同步處理器以及服務監聽器。
- 全量同步: 新加入叢集的節點會請求全量資料,其他節點傳送全量資料; 新節點收到全量資料後,進行校驗和處理
- 增量同步:全量同步完成後,後續的同步是基於增量同步,只同步新產生的資料。 每個節點定期向其他節點傳送增量請求,同步到本地進行處理
- 心跳與確認:節點之前定時傳送心跳,以檢測對方的存活狀態
2. Raft 協議 - CP
核心參考: com.alibaba.nacos.naming.consistency.persistent.raft.RaftCore
思想: 有一個主節點,所有的寫請求打到主節點。主節點然後同步阻塞將資料同步給其他節點,如果半數以上成功,就代表寫入成功; 否則寫入失敗會丟擲非法狀態異常,可能就需要排查nacos 叢集是否正常。
核心思想:
- 領導選舉:
(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). 所有的寫請求會到達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