本文目录
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秒就返回错误。