
- register:注册中心,每个实例都会报错服务的名称和地址
- server:服务实例
- group:命名空间,每个服务实例都有相同的命名空间,这些 group 在逻辑上是相连的
- PeerPicker:采用一致性哈希把所有实例映射到哈希环上,负载均衡
- Peer:调用其它实例,底层通过 grpc 封装
@[TOC]
register - 注册中心
1 | package registy |
代码解析
1. 配置 (Config 和 DefaultConfig)
1 | // Config 定义etcd客户端配置 |
Config结构体: 定义了连接 etcd 所需的配置,包括 etcd 服务器的地址列表 (Endpoints) 和连接超时时间 (DialTimeout)。DefaultConfig变量: 提供了一个默认配置,方便快速使用。默认连接本地的 etcd (localhost:2379),超时时间为 5 秒。
2. 核心注册函数 (Register)
这是实现服务注册最关键的函数。
1 | func Register(svcName, addr string, stopCh <-chan error) error { |
- 函数签名: 接收三个参数:
svcName: 服务的名称,例如 “user-service” 或 “product-api”。addr: 服务的网络地址,例如 “192.168.1.10:8080” 或仅仅是一个端口 “:8080”。stopCh: 一个只读的 channel,用于接收停止信号。当服务准备关闭时,会向这个 channel 发送消息,以便代码执行服务注销的逻辑。
函数内部执行流程:
连接 etcd:
1
cli, err := clientv3.New(...)
使用配置创建一个 etcd 客户端实例 (
cli)。获取并处理地址:
1
2
3
4
5ip, err := getLocalIP()
// ...
if addr[0] == ':' {
addr = fmt.Sprintf("%s%s", ip, addr)
}如果传入的地址
addr是以:开头的(如:8080),代码会调用getLocalIP()函数获取本机的一个有效 IP 地址,并将其与端口拼接成一个完整的地址(如192.168.1.5:8080)。这样做是为了确保注册到 etcd 的地址是其他服务可以访问的。创建租约 (Lease):
1
grant, err := cli.Grant(context.Background(), 10)
这是服务注册中非常关键的一步。它向 etcd 请求一个租约,并设置了一个 10 秒的 TTL(Time-To-Live,存活时间)。
- 租约是什么? 租约就像一个有生命周期的“许可证”。所有与这个许可证关联的数据(Key-Value),都会在这个许可证过期后被 etcd 自动删除。
- 为什么用租约? 这是为了处理服务异常崩溃的情况。如果服务挂了,它就无法再为租约“续期”,租约会在 10 秒后过期,etcd 就会自动清理掉这个服务的信息,实现了服务故障的自动摘除。
注册服务 (Put Key-Value):
1
2key := fmt.Sprintf("/services/%s/%s", svcName, addr)
_, err = cli.Put(context.Background(), key, addr, clientv3.WithLease(grant.ID))- 它将服务信息作为一个 Key-Value 键值对写入 etcd。
- Key:
/services/服务名/服务地址,例如/services/user-service/192.168.1.5:8080。这种层级结构便于按服务名进行查询。 - Value: 服务地址
addr。 clientv3.WithLease(grant.ID): 最重要的一步,将这个键值对与刚刚创建的租约绑定。
保持租约存活 (KeepAlive):
1
keepAliveCh, err := cli.KeepAlive(context.Background(), grant.ID)
为了不让租约在 10 秒后过期,客户端必须定期向 etcd 发送“心跳”来为租约续期。
cli.KeepAlive会自动处理这个心跳过程。它返回一个 channelkeepAliveCh,用于接收续约的响应。启动后台 Goroutine 处理心跳和注销:
1
2
3
4
5
6
7
8
9
10
11
12
13go func() {
// ...
for {
select {
case <-stopCh:
// 服务注销...
cli.Revoke(ctx, grant.ID)
return
case resp, ok := <-keepAliveCh:
// 处理心跳响应...
}
}
}()这里启动了一个新的 goroutine 在后台运行,它同时监听两个 channel:
case <-stopCh: 如果从stopCh接收到信号,说明服务要正常关闭。此时会调用cli.Revoke()主动撤销租约,让 etcd 立即删除该服务的 Key,完成服务注销。case resp, ok := <-keepAliveCh: 不断地从心跳 channel 中读取响应。只要能成功读取,就说明心跳正常,租约被成功续期。如果这个 channel 被关闭 (ok变为false),通常意味着与 etcd 的连接断开,goroutine 也会退出。
3. 辅助函数 (getLocalIP)
1 | func getLocalIP() (string, error) { |
这个函数的作用是遍历当前机器的所有网络接口,找到一个非环回(non-loopback,即不是 127.0.0.1)的 IPv4 地址并返回。这是为了得到一个在局域网内可以被其他服务访问到的 IP 地址。
总结
这个 registy 包提供了一个健壮的服务注册流程:
- 启动时:服务调用
Register函数,将自己的服务名和地址注册到 etcd,并与一个租约绑定。 - 运行时:一个后台 goroutine 会持续向 etcd 发送心跳,为租约续期,确保服务信息不过期。
- 正常关闭时:服务通过
stopCh发送信号,代码会主动撤销租约,服务信息被立即从 etcd 中删除。 - 异常崩溃时:服务无法再发送心跳,租约会在 TTL(10秒)后自动过期,etcd 会自动删除服务信息。
这个机制完美地结合了主动注销和被动故障检测,是基于 etcd 实现服务发现(Service Discovery)的标准模式。
PeerPicker - 一致性哈希选择实例
Map - 一致性哈希
1 | package consistenthash |
这段代码的核心目标是解决分布式系统中如何将请求或数据均匀地分配到一组动态变化的节点(服务器)上的问题。
它主要实现了以下功能:
- 一致性哈希:当添加或删除节点时,只会影响到少量的数据映射,最大程度地减少数据迁移。
- 虚拟节点:通过为每个物理节点创建多个虚拟节点,使得数据在哈希环上分布得更加均匀。
- 动态负载均衡:它会持续监控每个节点的请求负载,如果发现负载不均,会自动增加低负载节点的虚拟节点数量、减少高负载节点的虚拟节点数量,从而实现负载的自动平衡。
核心概念解析
在深入代码之前,我们先理解几个关键概念:
- 哈希环 (Hash Ring):想象一个 0 到 $2^{32}-1$ 的闭环(像一个钟面)。代码中的
keys数组就是一个排好序的哈希环,存储了所有虚拟节点的哈希值。 - 虚拟节点 (Virtual Nodes / Replicas):为了避免节点在环上分布不均导致的数据倾斜,我们不直接将物理节点(如服务器 IP)放到环上,而是为每个物理节点创建多个“分身”,即虚拟节点。例如,节点
A可以有A-1,A-2, …A-10等多个虚拟节点,每个虚拟节点都有自己的哈希值。代码中的nodeReplicas记录了每个物理节点有多少个虚拟节点。 - 请求路由 (Request Routing):当一个请求的 key (例如,一个用户 ID) 到来时,计算它的哈希值,然后在环上顺时针寻找离它最近的一个虚拟节点,这个虚拟节点所属的物理节点就是处理该请求的节点。
代码分段解析
1. Map 结构体
这是整个一致性哈希算法的核心数据结构,存储了所有状态。
1 | type Map struct { |
mu: 因为节点可能会被动态添加或删除,所以需要用读写锁来保护内部数据在并发访问时的一致性。
2. New 构造函数和 Option 模式
1 | func New(opts ...Option) *Map { |
- 使用
Option函数式选项模式来创建Map实例,这是一种灵活的配置方式。 - 最重要的一点是,在创建实例时,它调用
m.startBalancer()启动了一个后台 goroutine,这个 goroutine 会周期性地检查并执行负载均衡。
3. Add 和 Remove (添加/移除节点)
Add(nodes ...string):- 加写锁 (
m.mu.Lock()) 以保证操作的原子性。 - 遍历要添加的节点列表。
- 对每个节点,调用
addNode()方法,根据配置中的DefaultReplicas(默认虚拟节点数)创建多个虚拟节点。 - 每个虚拟节点通过
fmt.Sprintf("%s-%d", node, i)生成唯一的字符串,然后计算哈希值。 - 将哈希值存入
m.keys,并将哈希值 -> 节点名的映射存入m.hashMap。 - 所有节点添加完毕后,调用
sort.Ints(m.keys)对哈希环进行重新排序,这对于后续的查找至关重要。
- 加写锁 (
Remove(node string):- 同样加写锁。
- 根据
m.nodeReplicas找到该节点对应的所有虚拟节点。 - 遍历并删除这些虚拟节点在
m.keys和m.hashMap中的数据。 - 从
m.nodeReplicas和m.nodeCounts中删除该节点。
4. Get(key string) (核心查找逻辑)
这是最高频调用的函数,用于根据一个 key 找到它应该被路由到的节点。
1 | func (m *Map) Get(key string) string { |
- 加读锁:因为只是查询,所以使用读锁,允许多个
Get操作并发执行。 - 计算哈希:计算输入
key的哈希值。 - 二分查找 (
sort.Search):这是性能的关键。它在排好序的m.keys(哈希环) 中,使用二分查找来快速找到第一个大于或等于 key 哈希值的虚拟节点。这等同于在环上顺时针寻找最近的节点。 - 环形处理:如果
idx等于len(m.keys),意味着 key 的哈希值超过了环上所有虚拟节点的哈希值。根据环形结构,它应该被分配给环上的第一个节点(idx = 0)。 - 更新负载:找到节点后,通过原子操作更新该节点的请求计数和总请求计数。这些数据是后续动态负载均衡的基础。
5. 动态负载均衡机制
这是此实现的精髓所在。
startBalancer():- 在
New()中被调用,启动一个后台 goroutine。 - 它使用一个
time.Ticker(默认每秒触发一次),周期性地调用checkAndRebalance()。
- 在
checkAndRebalance():- 检查时机:首先检查总请求数,如果样本太少(如小于 1000),则不进行调整,避免因数据不足导致误判。
- 计算负载:计算出平均每个节点的负载 (
avgLoad)。 - 判断失衡:遍历所有节点,计算每个节点的负载与平均负载的差异。如果
最大差异 / 平均负载超过了配置的阈值 (LoadBalanceThreshold),则认为负载不均衡,触发rebalanceNodes()。
rebalanceNodes():- 加写锁:因为要大规模修改哈希环。
- 调整策略:
- 对于负载过高的节点 (
loadRatio > 1),按比例减少其虚拟节点数。 - 对于负载过低的节点 (
loadRatio < 1),按比例增加其虚拟节点数。 - 新的虚拟节点数会有一个上下限(
MinReplicas,MaxReplicas)以防止过度调整。
- 对于负载过高的节点 (
- 执行调整:先移除该节点所有旧的虚拟节点,然后根据计算出的新数量,重新添加虚拟节点。
- 重置和排序:调整完所有节点后,重置负载计数器 (
nodeCounts,totalRequests),并对哈希环 (m.keys) 重新排序。
总结
这个 consistenthash 包实现了一个功能完备且智能的一致性哈希算法。它的优点非常突出:
- 高性能:
Get操作使用二分查找,时间复杂度为 $O(\log N)$,其中 N 是虚拟节点的总数。 - 并发安全:使用读写锁和原子操作,可以在高并发环境下安全使用。
- 高可用:当节点增删时,影响范围最小化,符合一致性哈希的核心思想。
- 自适应负载均衡:最亮眼的功能。它能自动感知节点的负载压力,并通过动态调整虚拟节点权重(数量)来“削峰填谷”,使得整个系统负载更加平滑,大大提升了系统的稳定性和资源利用率。
PeerPicker
1 | package main |
这段 Go 代码实现了一个分布式缓存系统中至关重要的组件:一个动态节点选择器 (dynamic peer picker)。它的主要工作是判断集群中的哪个节点(或称“对等节点”)应该负责处理一个特定的缓存键。
它巧妙地将 etcd 的服务发现机制与一致性哈希图相结合,以一种可扩展且容错的方式实现了这一目标。
总体目标
想象一下,你运行了多个缓存服务器。当一个针对键(比如 "user:123")的请求到达时,接收该请求的服务器如何知道是应该自己处理,还是应该将其转发给另一个服务器呢?
这段代码就解决了这个问题。ClientPicker 会持续监控一个中心的 etcd 注册表,从而知道当前有哪些服务器在线。然后,它使用一个一致性哈希环,将任意给定的键映射到一个特定的服务器。这使得它成为了这个分布式缓存的“交通控制器”或“路由器”。
代码分段解析
1. 接口 (PeerPicker, Peer)
1 | type PeerPicker interface { |
Peer: 定义了一个远程缓存节点能做什么。它是对另一个服务器连接的抽象,允许你Get(获取)、Set(设置)或Delete(删除)数据。PeerPicker: 定义了主组件需要遵守的契约。其核心方法PickPeer是逻辑的核心:给定一个键,它返回应该与之通信的正确的Peer。
2. ClientPicker 结构体
这是节点管理逻辑的具体实现和中枢神经系统。
1 | type ClientPicker struct { |
- 它知道自己的地址 (
selfAddr),以避免向自己发起网络请求。 consHash是我们之前看到的一致性哈希实现,它维护着哈希环。clients是一个“电话簿”,记录了到所有其他已知节点的活动连接。
3. 创建与初始化 (NewClientPicker)
当一个 ClientPicker 被创建时:
- 它会初始化自己的状态(地址、空映射等)。
- 它创建一个带有
cancel函数的context,这是管理后台任务生命周期的标准 Go 模式。 - 它连接到
etcd。 - 最重要的是,它调用
startServiceDiscovery()来开始寻找它的对等节点。
4. 服务发现:动态核心
这是代码中最关键的部分。它确保了对等节点列表始终保持最新。它分两个阶段工作:
阶段一:引导启动 (fetchAllServices)
- 当一个节点启动时,它会向
etcd发起一个Get请求,获取前缀为/services/kama-cache/的所有键值对。 - 这会拉取到集群中当前所有已注册节点的快照。
- 它遍历这些节点,并调用
p.set()将每一个节点添加到它的一致性哈希环中,并建立一个客户端连接。
阶段二:持续监听 (watchServiceChanges 和 handleWatchEvents)
- 在初始拉取之后,它会启动一个后台 goroutine 来创建一个
etcd watcher。 - 这个 watcher 会高效地监听
/services/kama-cache/前缀下的任何变动。它不需要持续地轮询etcd。 - 当一个新节点加入集群时,
etcd会发送一个PUT事件。handleWatchEvents捕捉到这个事件,并调用p.set(addr)来添加这个新节点。 - 当一个节点离开集群时(无论是正常关闭还是崩溃导致
etcd的租约过期),etcd会发送一个DELETE事件。handleWatchEvents捕捉到这个事件,关闭到该节点的连接,并调用p.remove(addr)将其从哈希环和客户端映射中移除。
5. 核心逻辑: PickPeer(key string)
这是缓存系统其他部分会调用的函数,用于为某个键找到正确的节点。
1 | func (p *ClientPicker) PickPeer(key string) (Peer, bool, bool) { |
这个逻辑简单而强大:
- 它询问
consistenthash.Map,哪个节点的地址 (addr) 负责给定的key。 - 它检查返回的
addr是否是它自己的地址 (selfAddr)。如果是,它返回self=true,告诉调用者应该在本地处理请求,而不是发起网络调用。 - 如果地址属于另一个节点,它就在
clients映射中查找活动的客户端连接并返回它。
总结
这段代码扮演了一个分布式系统的智能粘合剂的角色。它创建了一个 ClientPicker,该选择器能够:
- 连接到一个
etcd集群。 - 通过在启动时拉取列表,然后“监听”实时变化(节点的加入或离开)来发现所有其他的对等节点。
- 维护一个包含所有活动节点的、最新的一致性哈希环。
- 提供一个简单的
PickPeer方法,该方法可以为任意给定的键即时确定正确的节点,从而在整个集群中实现高效和正确的请求路由。
group - 缓存
ByteView - 存储数据的格式
1 | type ByteView struct { |
store - 缓存接口
1 | package store |
lru - 最近最少使用算法
1 | package store |
Cache - 对 store 的封装
1 | package main |
group
1 | package main |
这段 Go 代码定义了一个分布式缓存系统的核心组件:Group。可以把它理解为一个拥有特定命名空间(例如 “user-cache” 或 “product-cache”)的缓存管理器。
这个 Group 非常强大,它统一了本地缓存、对等节点(分布式)缓存和数据源(回源) 的数据获取逻辑,并内置了防止“缓存惊群”的机制。
核心目标与设计理念
Group 的目标是为用户提供一个简单的 Get, Set, Delete 接口来操作缓存,而将复杂的内部逻辑完全封装起来。这些复杂逻辑包括:
- 分层缓存:优先从本地内存获取,失败则尝试从其他远程节点获取,如果都失败了,最后才从最慢的数据源(如数据库)加载。
- 分布式协作:通过
PeerPicker接口,它可以知道一个key应该由哪个节点负责,并向其发起请求。 - 防止缓存惊群 (Cache Stampede):使用
singleflight机制,确保在缓存失效的瞬间,即使有成百上千个并发请求访问同一个key,也只有一个请求会真正去加载数据,其他请求则会等待这个结果,从而避免了对底层数据源的冲击。 - 数据统计与监控:内置了详细的统计功能,可以监控命中率、加载时间、来源等关键指标。
- 生命周期管理:可以被创建、获取、销毁,并能优雅地关闭。
代码分段解析
1. Group 结构体:缓存组的核心
1 | type Group struct { |
getter(回源加载器): 这是Group的灵魂。当一个数据在所有缓存层(本地和远程)都找不到时,Group就会调用这个用户提供的getter函数去获取“源数据”(比如从数据库查询)。peers(节点选择器): 这是实现分布式能力的关键。它帮助Group决定一个key是应该在本地处理,还是应该去问集群里的哪个“兄弟节点”。loader(singleflight): 这是一个重要的性能优化工具。它确保对于同一个key的数据加载过程,在同一时间内只执行一次。
2. Get 方法:核心读取路径 (Read Path)
这是最复杂也最能体现其设计思想的方法。当调用 g.Get("some-key") 时,会发生以下事情:
检查状态:首先检查
Group是否已经关闭。查找本地缓存 (
mainCache):- 命中:如果在本地内存中找到了数据,立即增加
localHits计数,并直接返回结果。这是最快的路径。 - 未命中:如果本地没有,增加
localMisses计数,然后进入下一步。
- 命中:如果在本地内存中找到了数据,立即增加
调用
load方法: 本地既然没有,就需要从其他地方加载。load方法负责这个过程。
3. load 和 loadData 方法:数据加载与防惊群
load 方法的逻辑非常精妙:
g.loader.Do(key, ...): 它做的第一件事不是直接去加载数据,而是将真正的加载逻辑包裹在singleflight的Do方法里。- 作用:如果此时有 100 个 goroutine 都在请求同一个
key,singleflight会保证只有一个 goroutine 会去执行传入的函数(即g.loadData),其他 99 个则会阻塞等待,直到那个 goroutine 完成并返回结果。所有等待者都会收到同一份结果。这就是防止缓存惊群的关键。
- 作用:如果此时有 100 个 goroutine 都在请求同一个
g.loadData(key, ...): 这是被singleflight保护的、真正的加载逻辑。- 尝试从远程节点获取:
- 它会调用
g.peers.PickPeer(key)来找到负责这个key的远程节点。 - 如果找到了一个远程节点(并且不是自己),它会调用
g.getFromPeer向该节点发起网络请求。 - 如果远程节点成功返回数据,增加
peerHits计数,将数据返回。 - 如果远程节点获取失败,增加
peerMisses计数,并继续执行下一步(回源)。
- 它会调用
- 回源 (Fallback to Source):
- 如果集群中没有其他节点,或者从远程节点获取失败,那么就到了最后一步:调用用户传入的
g.getter.Get(...)方法。 - 这个
getter会从最终数据源(如数据库、API 服务等)获取数据。 - 成功后,增加
loaderHits计数。
- 如果集群中没有其他节点,或者从远程节点获取失败,那么就到了最后一步:调用用户传入的
- 尝试从远程节点获取:
填充本地缓存:
load方法在从singleflight获得数据后(无论是从远程节点还是数据源加载的),会把这份数据存入g.mainCache中,以便下一次请求能够快速命中本地缓存。
4. Set 和 Delete 方法:写入与删除路径 (Write Path)
这两个方法处理数据的变更。
- 本地操作:首先,它们会直接修改或删除本地缓存 (
g.mainCache) 中的数据。 - 分布式同步 (
syncToPeers):- 一个关键的检查是
isPeerRequest。它通过检查context中是否有"from_peer"这个标记来判断当前操作是否是由另一个节点同步过来的。 - 如果这是一个原始请求(不是同步请求),并且配置了分布式节点 (
g.peers != nil),它会启动一个 goroutine 去执行syncToPeers。 syncToPeers会找到负责该key的节点,并向其发送一个Set或Delete请求。在发送请求时,它会在context中加入"from_peer": true标记,以防止接收方节点再次进行同步,从而避免无限循环。
- 一个关键的检查是
总结
这个 Group 模块是一个设计精良、功能完备的缓存管理单元。它优雅地整合了多种缓存策略:
- 对于读操作 (
Get):实现了本地缓存 -> 远程节点缓存 -> 数据源的三级回退策略,并用singleflight解决了高并发下的缓存惊群问题。 - 对于写操作 (
Set/Delete):实现了更新本地 -> 异步同步到对应远程节点的策略,并通过context标记避免了同步风暴。
通过将这些复杂的逻辑封装在一个 Group 中,上层应用可以像使用一个简单的本地缓存一样使用这个强大的分布式缓存系统。
server - 节点
1 |
|
这段 Go 代码定义了一个gRPC 服务器,它是分布式缓存集群中每个节点的“骨架”和“通信中心”。
这个服务器负责处理来自其他节点(或客户端)的网络请求,并将其委托给前一个代码中我们分析过的 Group 模块来执行实际的缓存操作。同时,它还负责向 etcd 注册自己,以便集群中的其他成员能够发现它。
下面我将为你详细解释其设计和工作流程。
总体目标
Server 的目标是将内部复杂的缓存逻辑(由 Group 提供)通过一个标准化的、高性能的网络接口(gRPC)暴露出去。它承担了以下几个关键职责:
- 网络监听与服务:启动一个 gRPC 服务,监听一个指定的网络地址和端口,准备接收请求。
- 服务注册:在启动时,向
etcd服务注册中心注册自己的地址,宣告“我上线了,可以提供缓存服务”。 - 请求路由:接收 gRPC 请求(如
Get,Set,Delete),解析请求参数,并找到对应的Group来处理这些请求。 - 安全通信:支持 TLS,确保节点间的数据传输是加密的、安全的。
- 生命周期管理:能够优雅地启动和停止。停止时会从
etcd注销自己,确保不会有请求被发送到一个已经下线的节点。 - 健康检查:内置了 gRPC 的标准健康检查服务,便于外部系统(如 Kubernetes、Prometheus)监控其运行状态。
代码分段解析
1. Server 结构体:节点的核心
1 | type Server struct { |
grpcServer: 这是真正负责处理网络通信的引擎。etcdCli和svcName: 这两个字段是实现服务发现的关键。服务器用它们来告诉etcd:“我是 ‘svcName’ 服务的一个实例,我的地址是 ‘addr’”。
2. 配置选项 (ServerOptions 和函数式选项模式)
1 | type ServerOptions struct { |
- 代码使用了非常优雅的函数式选项模式 (
Functional Options Pattern)。这使得创建Server时配置非常灵活,可以只配置需要的选项,其他使用默认值。例如NewServer(addr, name, WithTLS("cert.pem", "key.pem"))。
3. NewServer 方法:服务器的创建与初始化
这是创建服务器实例的构造函数,它执行了一系列初始化步骤:
- 应用配置:解析传入的
ServerOption,设置etcd地址、TLS 等配置。 - 连接 Etcd:创建一个
etcd客户端,为后续的服务注册做准备。 - 创建 gRPC 服务器:
- 初始化
grpc.Server。 - 如果启用了 TLS (
opts.TLS == true),它会调用loadTLSCredentials加载证书和私钥,并将安全凭证应用到 gRPC 服务器上。
- 初始化
- 注册服务:这是最关键的步骤之一。
pb.RegisterKamaCacheServer(srv.grpcServer, srv): 这行代码的作用是**“连接”**。它告诉grpcServer:“任何实现了KamaCacheServer接口的 RPC 调用(比如Get,Set),都应该由srv这个对象(即我们正在创建的Server实例)的同名方法来处理”。healthpb.RegisterHealthServer(...): 注册标准的健康检查服务。
4. Start 和 Stop 方法:生命周期管理
Start():net.Listen("tcp", s.addr): 在指定的地址上打开一个 TCP 监听端口。registy.Register(...): 在一个新的 goroutine 中调用Register函数,将自己注册到etcd。注册成功后,这个 goroutine 会通过心跳机制维持租约。s.grpcServer.Serve(lis): 这是一个阻塞调用。它启动 gRPC 服务器的主循环,开始接收和处理来自客户端或其他节点的请求。程序会一直停留在这里直到服务器停止。
Stop():close(s.stopCh): 关闭stopCh通道。这会向registy.Register中的 goroutine 发送一个停止信号,使其主动从etcd中撤销租约并注销服务。s.grpcServer.GracefulStop(): 优雅地停止 gRPC 服务器。它不会立即切断现有连接,而是会停止接收新连接,并等待当前正在处理的请求完成后再关闭。s.etcdCli.Close(): 关闭与 etcd 的连接,释放资源。
5. RPC 处理方法 (Get, Set, Delete)
这些方法是 gRPC 服务的具体实现。它们是连接网络层和业务逻辑层(Group)的桥梁。
工作流程:
- 接收到一个 gRPC 请求 (
req)。 - 从请求中获取
group名称。 - 调用
GetGroup(req.Group)从全局的groups映射中找到对应的Group实例。 - 调用该
group实例的Get,Set,Delete方法来执行真正的缓存逻辑。 - 将
group返回的结果打包成 gRPC 的响应格式并返回。
- 接收到一个 gRPC 请求 (
关键细节 (
Set方法):1
ctx = context.WithValue(ctx, "from_peer", true)
在
Set和Delete方法中(示例中Set更明显),它向context中添加了"from_peer": true标记。这是为了通知group.Set方法,这个请求是从另一个对等节点同步过来的,而不是一个原始的用户请求。group.Set看到这个标记后,就不会再尝试将这个变更同步给其他节点,从而避免了无限的同步循环。
总结
这个 Server 模块是整个分布式缓存节点的入口和执行器。它将之前我们看到的所有组件串联起来:
- 它通过 gRPC 提供了一个高性能、标准化的网络接口。
- 它使用
registy模块在启动时向etcd注册自己,并在关闭时注销,从而实现了服务的动态发现。 - 它接收网络请求后,将其无缝地传递给
Group模块,让Group去处理复杂的缓存读写、回源和节点间数据获取逻辑。
简而言之,Server 让抽象的 Group 缓存逻辑变成了一个可以在网络中被发现、被访问、能够与其他节点协同工作的、有生命周期的具体服务实例。