目录

Go-从0实现简单分布式缓存-7增加etcd和gRPC功能

【Go | 从0实现简单分布式缓存】-7:增加etcd和gRPC功能

https://i-blog.csdnimg.cn/direct/d79d4f90cfd84adca7f434c7018f2754.png

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

  • 当一个节点需要获取不在本地缓存的数据时,它会通过一致性哈希算法选择一个节点
  • 使用 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

https://i-blog.csdnimg.cn/direct/b960c4a8c8d349a79456099c71788b8a.png 首先来看看proto文件,定义了整个系统的RPC接口和消息格式。 跟GeeCache一样,有两个GetRequestGetResponse,是用来Get获取缓存的请求和响应。 value :缓存的值,使用 bytes 类型可以存储任意二进制数据。 SetRequestSetResponse 是设置缓存的请求消息和响应消息。 这里请求消息有: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 客户端和服务端接口代码

https://i-blog.csdnimg.cn/direct/dd7dfe7b32004644b8bf62fe655d8924.png Etcd 结构体是对 etcd 客户端的封装,包含了与 etcd 交互所需的核心组件。其中 EtcdClietcd 的客户端实例,用于执行各种 etcd 操作; leaseId 存储租约 ID,用于后续的租约管理; ctxcancel 则用于控制上下文和超时处理。将 etcd 的操作封装在一个结构体中,便于统一管理和使用。 NewEtcd 函数负责初始化 etcd 客户端连接。它接收 etcd 服务器的地址列表,创建一个新的 etcd 客户端实例,并设置连接超时时间。这里使用了 clientv3.New 方法创建客户端,这是 etcd v3 API 的标准做法。还创建一个带超时的上下文,用于控制后续操作的超时行为。这是与分布式系统交互的常见模式,可以避免因网络问题导致的长时间阻塞。 DialTimeout 是客户端尝试连接到 etcd 服务时的超时时间。如果在指定时间内无法建立连接,客户端会返回错误。 https://i-blog.csdnimg.cn/direct/2165bcd178ae45c7b3f58b13e9aecaa6.png CreateLease 为注册在etcd上的节点创建租约。 由于服务端无法保证自身是一直可用的,可能会宕机,所以与etcd的租约是有时间期限的,租约一旦过期,服务端存储在etcd上的服务地址信息就会消失。 如果服务端是正常运行的,etcd中的地址信息又必须存在,因此发送心跳检测,一旦发现etcd上没有自己的服务地址时,请求重新添加(续租)。 CreateLease 函数实现了 etcd 的租约创建机制。在分布式系统中,租约是一种重要的机制,用于表示节点的存活状态。该函数调用 Grant 方法向 etcd 申请一个指定过期时间的租约,并将获得的租约 ID 保存在 Etcd 结构体中 。租约机制是 etcd 实现服务健康检查的基础,一旦租约过期,与之关联的键值对会被自动删除,这样就能自动清理已经宕机的节点信息。 https://i-blog.csdnimg.cn/direct/e9da0f0d05b84337a703033ebe349450.png BindLease 函数将服务信息与租约绑定 。它使用 Put 方法将服务名称和地址作为键值对存储到 etcd 中,并通过 clientv3.WithLease 选项将这个键值对与之前创建的租约关联起来。这样,当租约过期时,这个服务信息也会被自动删除。这种机制确保了 etcd 中只保存活跃节点的信息,是实现服务自动注册和注销的关键。 https://i-blog.csdnimg.cn/direct/30bd44b818ca4851825ff4af7452d840.png KeepAlive 函数实现了租约的续约机制。它调用 etcdKeepAlive 方法,定期向 etcd 发送心跳,以延长租约的有效期。该函数启动一个 goroutine 持续监听续约响应通道,如果收到 nil 响应,表示续约失败,服务可能已经与 etcd 断开连接。这种心跳机制是分布式系统中保持节点活跃状态的标准做法,确保了只要服务正常运行,其信息就会一直保存在 etcd 中。 https://i-blog.csdnimg.cn/direct/30052f0efb534fc2bbf6595c0fff0f29.png RegisterServer 函数是服务注册的入口,它整合了前面几个函数的功能,完成服务的完整注册流程。首先创建租约,然后将服务信息与租约绑定,接着启动心跳保持租约活跃,最后使用 etcdendpoints 管理器注册服务端点。 使用 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 客户端进行服务发现和负载均衡。

https://i-blog.csdnimg.cn/direct/db99a888a5aa4d299bb476d78c0ccdb8.png 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 会使用这个 resolveretcd 中查找所有注册在该服务名下的实例地址。这种机制使得客户端可以通过服务名而非具体 IP 地址进行通信,实现了服务发现的解耦。 https://i-blog.csdnimg.cn/direct/9e50dede6e31494ab8dab2991bcd199b.png GetAddrByName 函数提供了一种更直接的服务发现方式。它接收 etcd 客户端和服务名称作为参数,直接从 etcd 中查询该服务名对应的地址。函数首先创建一个带超时的上下文,然后使用 etcd 的 Get 方法查询指定键(服务名)的值(服务地址)。这种方式适用于简单的键值查询,不依赖 gRPCresolver 机制。 这两种方式分别满足不同场景的需求。例如, DialPeer 用于建立 gRPC 连接进行缓存操作,而 GetAddrByName 则用于服务器启动时发现其他节点。 两个函数都使用了 context.WithTimeout 创建带超时的上下文,这是 Go 语言中处理超时的标准模式。在分布式系统中,网络延迟和服务不可用是常见问题,设置适当的超时可以避免长时间阻塞,提高系统的可用性和响应速度。在这个文件中,超时时间设置为 2 秒,这是一个较为合理的值,既能容忍一定的网络延迟,又不会因等待过长而影响用户体验。

https://i-blog.csdnimg.cn/direct/aee1e6fab1074a1abbe771aeb2639ddf.png


https://i-blog.csdnimg.cn/direct/64f417bba9c04d39bba4d09012527ac5.png Client 结构体是对远程节点通信客户端的封装,包含两个关键字段: Name 表示目标服务节点的名称,用于在 etcd 中查找对应的服务地址; Etcdetcd 客户端的引用,用于进行服务发现。 https://i-blog.csdnimg.cn/direct/da508c362cc84183829ecd9dff5f8949.png Get 函数实现了从远程节点获取缓存数据的功能。它首先通过 DialPeer 函数利用 etcd 进行服务发现,获取目标节点的 gRPC 连接。然后创建 gRPC 客户端,构造请求参数,并设置超时上下文,最后发起 RPC 调用并处理响应。这个函数展示了 gRPC 客户端的标准使用模式,以及如何将 etcd 服务发现与 gRPC 调用结合起来。 https://i-blog.csdnimg.cn/direct/6e50491e49084d9a82a35c474281052a.png Set 函数实现了向远程节点设置缓存数据的功能。它的流程与 Get 函数类似,也是先通过 etcd 获取连接,然后创建 gRPC 客户端发起调用。不同的是,它需要处理更多的参数,包括缓存值、过期时间和热点标记。这个函数展示了如何处理复杂的 RPC 参数,以及如何处理 RPC 调用的错误和响应。 var _ PeerGetter = (*Client)(nil) 通过这种方式验证 Client 结构体是否实现了 PeerGetter 接口。 这是 Go 语言中常用的接口实现检查技巧,如果 Client 没有完全实现 PeerGetter 接口,编译器会报错。这种静态检查可以早期发现接口实现的问题,提高代码质量。

https://i-blog.csdnimg.cn/direct/c80d071a89b14f7b9d3755530f2e3893.png Server 结构体是 分布式缓存系统的服务端核心组件,它实现了 gRPC 服务接口和节点选择功能。结构体中包含多个重要字段: status 标记服务运行状态; self 记录自身 IP 地址; peers 是一致性哈希环,用于节点选择; etcdetcd 客户端实例,用于服务发现; clients 存储了所有远程节点的客户端实例。 NewServer 函数负责创建并初始化一个 Server 实例。它接收服务名称、自身地址和 etcd 客户端作为参数,初始化一致性哈希环和客户端映射。 https://i-blog.csdnimg.cn/direct/00e877b5f5d54a0cac23bb06da4f570c.png GetSet 方法实现了 gRPC 服务接口,分别用于处理远程缓存获取和设置请求。当其他节点通过 gRPC 调用这些方法时,服务器会从本地缓存组中获取或设置数据,并返回相应的结果。这两个方法是分布式缓存系统中节点间数据交换的核心实现,它们将 gRPC 请求转换为本地缓存操作,实现了远程调用与本地存储的桥接。 https://i-blog.csdnimg.cn/direct/9126a260b2ac4093a6621fb030b96a38.png SetPeers 方法是服务发现和节点管理的关键。它接收一组节点名称,通过 etcd 查找每个节点的 IP 地址,然后将这些地址添加到一致性哈希环中,并创建对应的客户端实例。这个方法展示了如何使用 etcd 进行服务发现:通过 GetAddrByName 函数查询 etcd 中存储的服务地址信息。 https://i-blog.csdnimg.cn/direct/c85c333cac2c407285bdb3048e61a384.png PickPeer 方法实现了 connect.PeerPicker 接口,用于根据键选择合适的远程节点。它使用一致性哈希算法确定键应该存储在哪个节点上,然后返回该节点的客户端实例。这个方法是分布式缓存系统中数据分片的核心实现,它确保了相同的键总是被路由到相同的节点,提高了缓存的命中率和一致性。 https://i-blog.csdnimg.cn/direct/6217bcd5e390447baa56cca3a38f8e0c.png StartServer 方法负责启动 gRPC 服务器。它首先检查服务是否已经启动,然后初始化 TCP 监听器,创建 gRPC 服务器实例,注册服务处理器,最后开始监听和处理请求。这个方法是服务器组件的入口点,它将所有组件组合在一起,形成一个完整的服务节点。

当客户端通过 API 请求获取缓存数据时:

  1. HTTP 服务器接收请求,调用 group.Get 方法获取数据。
  2. group.Get 首先尝试从本地缓存获取数据,如果命中则直接返回。
  3. 如果本地缓存未命中, group.Get 会调用 group.load 方法加载数据。
  4. group.load 会先检查是否有远程节点负责存储该键的数据:
  • 如果有,则通过 PeerPicker.PickPeer 选择合适的节点
  • 然后通过 PeerGetter.Get 从远程节点获取数据
  • 这个过程涉及 gRPC 调用,由 Client.Get 方法实现
  1. 如果远程节点获取失败或没有远程节点负责该键,则调用回调函数从数据源获取数据。
  2. 获取到数据后,将其存储到本地缓存并返回给客户端。

当客户端通过 API 请求设置缓存数据时:

  1. HTTP 服务器接收请求,解析参数,调用 group.Set 方法设置数据。
  2. group.Set 会根据键选择合适的节点:
  • 如果是本地节点,则直接设置本地缓存
  • 如果是远程节点,则通过 PeerGetter.Set 设置远程节点的缓存
  • 这个过程也涉及 gRPC 调用,由 Client.Set 方法实现
  1. 设置成功后,返回成功响应给客户端。

可以看到,这里我们经常使用上下文,并且还要加入超时的设定。 使用带超时的上下文(context.WithTimeout)是一种非常重要的编程实践,它能有效防止系统因为某些操作卡住而导致资源耗尽。 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() 创建一个新的上下文,它会在2秒后自动超时。同时返回一个cancel函数,可以手动取消这个上下文。defer cancel() 确保无论函数如何返回,都会调用cancel函数释放资源。 打个比方,假设我们去餐厅点餐,如果没有超时控制 :你告诉服务员"我要一份牛排",然后一直等,不管厨房是否忙碌、是否缺货,你可能会一直等下去。 有超时控制 :你告诉服务员"我要一份牛排,但我只能等15分钟"。如果15分钟内上菜了,太好了;如果没有,你就可以取消订单离开,不用无限期等待。 如果没有超时控制,当目标节点宕机或网络异常时,请求可能会一直阻塞,有了2秒的超时控制,即使目标节点无响应,最多也只会等待2秒就返回错误。