本文目錄
- 1.序
- 2.引入etcd
- 緩存流程
- 項目結構
- 3.gocachepb.proto
- 4.服務注冊register.go
- 5.服務發現discover.go
- 6.gRPC客戶端client.go
- peers.go
- client.go
- 7.gRPC服務端實現server.go
- 一些問題
- 緩存獲取流程
- 緩存設置流程
- 為什么要帶超時的上下文?
1.序
GeeCache項目并沒有引入服務發現,對于現代分布緩存系統,服務發現和節點間通信是兩個重要環節,所以可以引入etcd和gRPC來使得系統更加健壯和高效。
etcd作為一個高可用的分布式鍵值存儲系統,扮演著服務注冊與發現的角色。通過etcd,緩存節點可以動態地注冊自己的存在,同時發現其他節點的位置。這種機制使得系統能夠自動適應節點的加入和退出,無需手動配置。
而且etcd的租約機制提供了節點健康檢查的能力,當某個節點宕機時,系統能夠及時感知并進行相應調整,保證整個緩存集群的可用性。
gRPC則解決了節點間高效通信的問題。相比傳統的HTTP/JSON通信方式,gRPC基于HTTP/2協議和Protocol Buffers序列化格式,提供了更高的性能和更低的延遲。傳輸前使用 protobuf 編碼,接收方再進行解碼,可以顯著地降低二進制傳輸的大小。另外一方面,protobuf 可非常適合傳輸結構化數據,便于通信字段的擴展。GeeCache中只是單純用了protobuf進行通信。
在緩存系統中,節點間需要頻繁交換數據,如緩存查詢、更新等操作,gRPC的高效通信能力顯著提升了系統的吞吐量。
本文所參考的部分實現代碼是Github上的cache項目,原地址:https://github.com/Spr1n9/springcache
2.引入etcd
緩存流程
- 當一個節點需要獲取不在本地緩存的數據時,它會通過一致性哈希算法選擇一個節點
- 使用 etcd 進行服務發現,獲取該節點的地址
- 建立 gRPC 連接并調用遠程節點的 Get 方法獲取數據
- 如果遠程節點也沒有緩存該數據,它會從數據源獲取并返回
項目結構
這里我們需要編寫幾個新的文件,分別是:
cachepb.proto Protocol Buffers定義文件,用于定義gRPC服務接口和消息格式。
- 定義了 SpringCache 服務,包含 Get 和 Set 兩個RPC方法
- 定義了請求和響應的消息結構: GetRequest 、 GetResponse 、 SetRequest 和 SetResponse
- 這個文件是gRPC通信的基礎,通過protoc工具生成Go代碼
server.go 服務端核心實現,負責處理來自其他節點的緩存請求。
- 實現了gRPC服務接口,處理 Get 和 Set 請求
- 管理節點間的通信和緩存數據的分發
- 使用一致性哈希算法選擇節點
- 啟動gRPC服務器,接收來自其他節點的請求
client.go 客戶端實現,負責向其他節點發送請求。
- 封裝了gRPC客戶端調用邏輯
- 實現了 PeerGetter 接口,用于從遠程節點獲取或設置緩存
- 處理與遠程節點的連接和通信
discover.go 服務發現相關功能,負責發現和連接其他節點。
- 提供 DialPeer 函數,用于建立與其他節點的gRPC連接
- 提供 GetAddrByName 函數,通過etcd查詢服務名對應的地址
- 使用etcd的服務發現機制獲取節點信息
register.go 服務注冊相關功能,負責將節點注冊到etcd。
- 提供 Etcd 結構體,封裝etcd客戶端操作
- 實現租約創建、綁定和續約功能
- 提供 RegisterServer 方法,將服務注冊到etcd
3.gocachepb.proto
首先來看看proto
文件,定義了整個系統的RPC
接口和消息格式。
跟GeeCache
一樣,有兩個GetRequest
、GetResponse
,是用來Get
獲取緩存的請求和響應。
value
:緩存的值,使用 bytes
類型可以存儲任意二進制數據。
SetRequest
和 SetResponse
是設置緩存的請求消息和響應消息。
這里請求消息有:group
緩存組名、key
緩存鍵、 value
緩存值、expire
過期時間戳(Unix時間戳格式)、ishot
是否為熱點數據。
設置緩存的響應消息有:ok 返回bool
格式操作是否成功。
service SpringCache {rpc Get(GetRequest) returns (GetResponse);rpc Set(SetRequest) returns (SetResponse);
}
Get :獲取緩存,接收 GetRequest 參數,返回 GetResponse, Set :設置緩存,接收 SetRequest 參數,返回 SetResponse
使用protoc工具生成對應的兩個代碼如下,生成的代碼將被服務端和客戶端使用,服務端( server.go )實現了 SpringCacheServer 接口,處理 Get 和 Set 請求,客戶端( client.go )使用 SpringCacheClient 接口向遠程節點發送請求。
- springcachepb.pb.go :包含消息類型的定義和序列化/反序列化代碼
- springcachepb_grpc.pb.go :包含 gRPC 客戶端和服務端接口代碼
4.服務注冊register.go
Etcd
結構體是對 etcd
客戶端的封裝,包含了與 etcd
交互所需的核心組件。其中 EtcdCli
是 etcd
的客戶端實例,用于執行各種 etcd
操作; leaseId
存儲租約 ID,用于后續的租約管理; ctx
和 cancel
則用于控制上下文和超時處理。將 etcd
的操作封裝在一個結構體中,便于統一管理和使用。
NewEtcd
函數負責初始化 etcd
客戶端連接。它接收 etcd
服務器的地址列表,創建一個新的 etcd
客戶端實例,并設置連接超時時間。這里使用了 clientv3.New
方法創建客戶端,這是 etcd v3 API
的標準做法。還創建一個帶超時的上下文,用于控制后續操作的超時行為。這是與分布式系統交互的常見模式,可以避免因網絡問題導致的長時間阻塞。
DialTimeout
是客戶端嘗試連接到 etcd 服務時的超時時間。如果在指定時間內無法建立連接,客戶端會返回錯誤。
CreateLease
為注冊在etcd
上的節點創建租約。 由于服務端無法保證自身是一直可用的,可能會宕機,所以與etcd的租約是有時間期限的,租約一旦過期,服務端存儲在etcd上的服務地址信息就會消失。
如果服務端是正常運行的,etcd
中的地址信息又必須存在,因此發送心跳檢測,一旦發現etcd上沒有自己的服務地址時,請求重新添加(續租)。
CreateLease
函數實現了 etcd 的租約創建機制。在分布式系統中,租約是一種重要的機制,用于表示節點的存活狀態。該函數調用 Grant
方法向 etcd
申請一個指定過期時間的租約,并將獲得的租約 ID 保存在 Etcd 結構體中。租約機制是 etcd 實現服務健康檢查的基礎,一旦租約過期,與之關聯的鍵值對會被自動刪除,這樣就能自動清理已經宕機的節點信息。
BindLease
函數將服務信息與租約綁定。它使用 Put
方法將服務名稱和地址作為鍵值對存儲到 etcd 中,并通過 clientv3.WithLease
選項將這個鍵值對與之前創建的租約關聯起來。這樣,當租約過期時,這個服務信息也會被自動刪除。這種機制確保了 etcd 中只保存活躍節點的信息,是實現服務自動注冊和注銷的關鍵。
KeepAlive
函數實現了租約的續約機制。它調用 etcd
的 KeepAlive
方法,定期向 etcd
發送心跳,以延長租約的有效期。該函數啟動一個 goroutine
持續監聽續約響應通道,如果收到 nil 響應,表示續約失敗,服務可能已經與 etcd 斷開連接。這種心跳機制是分布式系統中保持節點活躍狀態的標準做法,確保了只要服務正常運行,其信息就會一直保存在 etcd 中。
RegisterServer
函數是服務注冊的入口,它整合了前面幾個函數的功能,完成服務的完整注冊流程。首先創建租約,然后將服務信息與租約綁定,接著啟動心跳保持租約活躍,最后使用 etcd
的 endpoints
管理器注冊服務端點。
使用 endpoints.NewManager
創建一個管理器,用于管理同一服務的所有實例。通過 AddEndpoint
方法,將當前實例的信息添加到服務組織中,并使用之前的租約進行關聯。
em
會將所有 UserService
的實例組織在一起,以 UserService/
為前綴存儲在 etcd
中。通過 AddEndpoint
,可以動態地添加服務實例。如果后續有更多實例(如 192.168.1.2:8080 和 192.168.1.3:8080),可以繼續調用 AddEndpoint 將它們添加到 etcd 中。比如下面的。
/CacheService/instance1:8001 (leaseID: 123)/instance2:8002 (leaseID: 456)/instance3:8003 (leaseID: 789)
每個實例都有自己的租約保證存活,而 Manager 則負責將這些實例組織在一起,方便 gRPC 客戶端進行服務發現和負載均衡。
5.服務發現discover.go
DialPeer
函數負責建立與遠程服務節點的 gRPC
連接。它接收 etcd 客戶端和服務名稱作為參數,返回一個 gRPC 連接。函數首先創建一個 etcd resolver
構建器,這是 gRPC 服務發現的核心組件。然后設置一個帶超時的上下文,確保連接操作不會無限期阻塞。最后,使用 grpc.DialContext
方法建立連接,其中 "etcd:///"+service
表示使用 etcd 作為服務發現機制,并指定要連接的服務名稱。
在 DialPeer
函數中, resolver.NewBuilder(c)
創建了一個 etcd resolver
構建器。這個構建器實現了 gRPC 的 resolver.Builder
接口,能夠解析 etcd 中存儲的服務信息。當使用 "etcd:///"+service
作為目標地址時,gRPC 會使用這個 resolver
從 etcd
中查找所有注冊在該服務名下的實例地址。這種機制使得客戶端可以通過服務名而非具體 IP 地址進行通信,實現了服務發現的解耦。
GetAddrByName
函數提供了一種更直接的服務發現方式。它接收 etcd 客戶端和服務名稱作為參數,直接從 etcd
中查詢該服務名對應的地址。函數首先創建一個帶超時的上下文,然后使用 etcd 的 Get 方法查詢指定鍵(服務名)的值(服務地址)。這種方式適用于簡單的鍵值查詢,不依賴 gRPC
的 resolver
機制。
這兩種方式分別滿足不同場景的需求。例如, DialPeer
用于建立 gRPC
連接進行緩存操作,而 GetAddrByName
則用于服務器啟動時發現其他節點。
兩個函數都使用了 context.WithTimeout
創建帶超時的上下文,這是 Go 語言中處理超時的標準模式。在分布式系統中,網絡延遲和服務不可用是常見問題,設置適當的超時可以避免長時間阻塞,提高系統的可用性和響應速度。在這個文件中,超時時間設置為 2 秒,這是一個較為合理的值,既能容忍一定的網絡延遲,又不會因等待過長而影響用戶體驗。
6.gRPC客戶端client.go
peers.go
client.go
Client
結構體是對遠程節點通信客戶端的封裝,包含兩個關鍵字段: Name
表示目標服務節點的名稱,用于在 etcd
中查找對應的服務地址; Etcd
是 etcd
客戶端的引用,用于進行服務發現。
Get
函數實現了從遠程節點獲取緩存數據的功能。它首先通過 DialPeer
函數利用 etcd 進行服務發現,獲取目標節點的 gRPC
連接。然后創建 gRPC 客戶端,構造請求參數,并設置超時上下文,最后發起 RPC 調用并處理響應。這個函數展示了 gRPC 客戶端的標準使用模式,以及如何將 etcd 服務發現與 gRPC 調用結合起來。
Set
函數實現了向遠程節點設置緩存數據的功能。它的流程與 Get
函數類似,也是先通過 etcd
獲取連接,然后創建 gRPC
客戶端發起調用。不同的是,它需要處理更多的參數,包括緩存值、過期時間和熱點標記。這個函數展示了如何處理復雜的 RPC 參數,以及如何處理 RPC
調用的錯誤和響應。
var _ PeerGetter = (*Client)(nil)
通過這種方式驗證 Client 結構體是否實現了 PeerGetter
接口。
這是 Go 語言中常用的接口實現檢查技巧,如果 Client 沒有完全實現 PeerGetter 接口,編譯器會報錯。這種靜態檢查可以早期發現接口實現的問題,提高代碼質量。
7.gRPC服務端實現server.go
Server
結構體是 分布式緩存系統的服務端核心組件,它實現了 gRPC
服務接口和節點選擇功能。結構體中包含多個重要字段: status
標記服務運行狀態; self
記錄自身 IP 地址; peers
是一致性哈希環,用于節點選擇; etcd
是 etcd
客戶端實例,用于服務發現; clients
存儲了所有遠程節點的客戶端實例。
NewServer
函數負責創建并初始化一個 Server
實例。它接收服務名稱、自身地址和 etcd 客戶端作為參數,初始化一致性哈希環和客戶端映射。
Get
和 Set
方法實現了 gRPC
服務接口,分別用于處理遠程緩存獲取和設置請求。當其他節點通過 gRPC
調用這些方法時,服務器會從本地緩存組中獲取或設置數據,并返回相應的結果。這兩個方法是分布式緩存系統中節點間數據交換的核心實現,它們將 gRPC
請求轉換為本地緩存操作,實現了遠程調用與本地存儲的橋接。
SetPeers
方法是服務發現和節點管理的關鍵。它接收一組節點名稱,通過 etcd 查找每個節點的 IP 地址,然后將這些地址添加到一致性哈希環中,并創建對應的客戶端實例。這個方法展示了如何使用 etcd 進行服務發現:通過 GetAddrByName
函數查詢 etcd 中存儲的服務地址信息。
PickPeer
方法實現了 connect.PeerPicker
接口,用于根據鍵選擇合適的遠程節點。它使用一致性哈希算法確定鍵應該存儲在哪個節點上,然后返回該節點的客戶端實例。這個方法是分布式緩存系統中數據分片的核心實現,它確保了相同的鍵總是被路由到相同的節點,提高了緩存的命中率和一致性。
StartServer
方法負責啟動 gRPC
服務器。它首先檢查服務是否已經啟動,然后初始化 TCP 監聽器,創建 gRPC
服務器實例,注冊服務處理器,最后開始監聽和處理請求。這個方法是服務器組件的入口點,它將所有組件組合在一起,形成一個完整的服務節點。
一些問題
緩存獲取流程
當客戶端通過 API 請求獲取緩存數據時:
-
HTTP 服務器接收請求,調用 group.Get 方法獲取數據。
-
group.Get 首先嘗試從本地緩存獲取數據,如果命中則直接返回。
-
如果本地緩存未命中, group.Get 會調用 group.load 方法加載數據。
-
group.load 會先檢查是否有遠程節點負責存儲該鍵的數據:
- 如果有,則通過 PeerPicker.PickPeer 選擇合適的節點
- 然后通過 PeerGetter.Get 從遠程節點獲取數據
- 這個過程涉及 gRPC 調用,由 Client.Get 方法實現
-
如果遠程節點獲取失敗或沒有遠程節點負責該鍵,則調用回調函數從數據源獲取數據。
-
獲取到數據后,將其存儲到本地緩存并返回給客戶端。
緩存設置流程
當客戶端通過 API 請求設置緩存數據時:
-
HTTP 服務器接收請求,解析參數,調用 group.Set 方法設置數據。
-
group.Set 會根據鍵選擇合適的節點:
- 如果是本地節點,則直接設置本地緩存
- 如果是遠程節點,則通過 PeerGetter.Set 設置遠程節點的緩存
- 這個過程也涉及 gRPC 調用,由 Client.Set 方法實現
-
設置成功后,返回成功響應給客戶端。
為什么要帶超時的上下文?
可以看到,這里我們經常使用上下文,并且還要加入超時的設定。
使用帶超時的上下文(context.WithTimeout)是一種非常重要的編程實踐,它能有效防止系統因為某些操作卡住而導致資源耗盡。
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
創建一個新的上下文,它會在2秒后自動超時。同時返回一個cancel函數,可以手動取消這個上下文。defer cancel() 確保無論函數如何返回,都會調用cancel函數釋放資源。
打個比方,假設我們去餐廳點餐,如果沒有超時控制 :你告訴服務員"我要一份牛排",然后一直等,不管廚房是否忙碌、是否缺貨,你可能會一直等下去。
有超時控制 :你告訴服務員"我要一份牛排,但我只能等15分鐘"。如果15分鐘內上菜了,太好了;如果沒有,你就可以取消訂單離開,不用無限期等待。
如果沒有超時控制,當目標節點宕機或網絡異常時,請求可能會一直阻塞,有了2秒的超時控制,即使目標節點無響應,最多也只會等待2秒就返回錯誤。