etcd架构
ectd是etc distributed的缩写。在Linux中的etc目录存储了系统的配置文件,所以etcd代表了分布式的配置中心系统。然而,它能够实现的功能远不是同步配置文件这么简单。etcd可以作为分布式协调的组件帮助我们实现分布式系统。使用etcd的重要项目包括coreos与k8s。etcd使用了go语言编写,底层使用了Raft协议。
etcd全局架构与原理

etcd抽象出了raft-http模块,由于etcd通常以分布式集群的方式部署,因此该层主要负责处理和其他etcd节点之间的网络通信。etcd内部使用了http进行通信,由于消息类型很多,心跳探活的数据量较小,快照信息较大(可达GB级别),所以有两种处理消息的通道,分别是Pipeline消息和Stream消息通道。
Stream 消息通道是etcd中节点与节点之间进行维护的长连接,它可以处理频蒙的小消息,还能复用HTTP底层的连接,不必每次通信都建立新的连接。而Pipeline消息通道用于处理快照这样数据量大的信息,处理完毕后连接会关闭。
etcd-raft模块,它是etcd的核心。该层实现了PRaft协议,可以实现节点状态的转移、节点的选举、数据处理等重要功能,确保分布式系统的一致性与故障容错性。我们已经知道,Raft中的节点有3种状态,分别是领导(Leader)、候选人(Candidates)和跟随者(Follower),在此基础上,etcd为Raft节点新增了一个预候选人(PreCandidate)。
Raft协议:如果节点收不到来自Leader的心跳检测,就会变为Candidates并开始新的选举。如果当前节点位于不足半数的网络分区中,短期内就不会影响集群的使用,但是在当前节点
不断发起选举的过程中,当前选举周期的Term号会不断增长,当网络分区消失后,由于该节点的 Term号高于当前集群中Leader节点的Term号,Raft协议会迫使当前的Leader 切换状态并开始新一轮的选举。
这种选举是没有意义的。为了解决这样的问题,etcd在选举之前插入了一个新的阶段叫作 PreVote,当前节点会先尝试连接集群中的其他节点,只有成功连接半数以上的节点,才开始新一轮的选举。
在etcd-raft模块的基础上,etcd进一步封装了raft-node模块。该模块充当上层模块与下层Raft模块之间的桥梁;并负责调用Storage模块,将记录(Record)存储到WAL日志文件以实现持久化。WAL日志文件可存储以下几种类型的记录。
- WAL文件的元数据,记录节点ID、集群 ID 信息。
- Entry记录,即客户端发送给服务器处理的数据。
- 集群的状态信息,包含集群的任期号、节点投票信息。
- 数据校验信息,可以校验文件数据的完整性与正确性。
- 快照信息,包含快照的相关信息,但不包含实际的快照数据,可以校验快照数据的完整性。
WAL 日志文件对于系统的稳定性至关重要,因为它在记录应用到状态机之前,确保了大多数节点已达成一致并实现了记录的持久化。这样,在节点崩溃并重启后,就能从WAL中恢复数据了。
WAL日志的数量与大小随着时间不断增加,可能超过可容纳的磁盘容量。同时,在节点宕机后,如果要恢复数据就必须从头到尾读取WAL 日志文件,耗时非常久。为了解决这一问题,etcd 会定期创建快照并保存到文件中,在恢复节点时会先加载快照数据,并从快照所在的位置后读取WAL文件,这就加快了节点的恢复速度。快照的数据也有单独的SNAP模块进行管理。
在raft-node模块之上是etcd-server模块,它的核心任务是执行Entry 对应的操作,在这个过程中提供限流操作与权限控制的能力,这些操作最终会使状态机到达最新的状态。etcd-server还会维护当前etcd集群的状态信息,并提供线性读的能力。
etcd-server模块为外部访问提供了一系列GRPC API。同时,利用GRPC-gateway作为反向代理,使得etcd-server模块具备向外部提供HTTPAPI的能力。
最后,etcd提供了客户端工具etcdctl和clientv3代码库,使用GRPC协议与etcd服务器交互。客户端支持负载均衡、节点间故障自动转移等机制,极大降低了业务使用etcd的难度,提升了开发的效率。
此外,etcd框架中还有一些辅助功能,例如权限管理、限流管理、重试、GRPC拦截器等。
etcd架构的优点
- 高内聚
- 低耦合:各个模块之间边界清晰,用接口进行交流与组合的设计给了程序极大的扩展性。
- 优雅的数据同步:在etcd中,极少使用互斥锁。更多的时候,它是借助协程与通道相互配合来传递信息,即完成了通信又优雅地解决了并发安全问题。
- 更高的读取性能:etcd实现了ReadIndex机制,Follower从Leader读取当前最新的Commit Index,同时Leader需要确保自己没有被未知的新Leader取代。它会发出新一轮的心跳,并等待集群中大多数节点确认,一旦收到确认信息,Leader就知道在心跳信息发出的那一刻,不可能存在更新的Leader了。也就是说,在那一刻,ReadIndex是集群中所有节点见过的最大的Commit Index。Follower会在自己的状态机上将日志至少执行到该Commit Index之后,然后查询当前状态机生成的结果,并将结果返回客户端。
- 可靠的Watch机制与高性能的并发处理:etcdv3版本将所有键值对的历史版本都存储了起来,这就让Watch机制的可靠性更高了,它实现了MVCC机制页提高了系统处理并发请求的数量。
GRPC与Protocol Buffers
在微服务的远程通信中,通常选择GRPC协议或遵循RESTful风格的HTTP,GRPC具有以下优势。
- 使用http/2来传输序列化后的二进制信息,传输速度更快。
- 可以为不同的语言生成对应的client库,外部访问非常便利。
- 使用Protocal Buffers定义API的行为,提供了强大的序列化与反序列化能力。
- 支持双向的流式传输。
GRPC默认使用Protocol Buffers来定义接口,其特点如下:
- 提供了与语言、框架无关的序列化与反序列化能力。
- 序列化生成的字节数组比JSON更小,同时序列化与反序列化的速度也比JSON更快。
- 有良好的向后与向前兼容性。
Protocol Buffers将接口语言定义在以.proto为后缀的文件中,proto编译器结合特定语言的运行库生成特定的SDK文件,这个SDK文件有助于我们在Client端访问,也有助于生成GRPC Server。
下面,通过一个案例来学习如何使用Protocol Buffers。
第一步,编写一个简单的文件hello.proto。
clike
syntax = "proto3";
option go_package="proto/greeter";
service Greeter{
rpc Hello(Request) returns (Response){}
}
message Request{
string name = 1;
}
message Response{
string greeting=2;
}
- syntax = "proto3";标识协议的版本,每个版本的语言可能有所不同,目前最新的使用最多的版本是proto3。
- option go_package定义生成的package名。
- service Greeter定义了一个服务Greeter,它的远程方法为Hello,Hello参数为结构体Request,返回值为结构体Response。
根据proto文件生成对应的协议文件需要首先完成前置工作:下载proto的编译器protoc,protoc指定版本的安装方式可以查看这篇博客:https://blog.csdn.net/qq_61635026/article/details/130780919。
此外,还需要安装protoc的go语言插件。
clike
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
第二步,运行protoc命令进行编译。编译完成后,将生成hello.pb.go和hello_grpc.go两个协议文件。编译命令如下:
clike
protoc -I . --go_out=. --go-grpc_out=. hello.proto
在hello_grpc.pb.go文件中,我们可以看到protoc自动生成了GreeterServer接口,其中包含了Hello方法。

第三步,在main函数中生成结构体Greeter,实现GreeterServer接口,然后调用协议文件中的pb.RegisterGreeterServer将Greeter注册到GRPC server中,代码如下:
clike
package main
import (
"context"
pb "crawler/proto/greeter/proto/greeter"
"google.golang.org/grpc"
"log"
"net"
)
type Greeter struct {
pb.UnimplementedGreeterServer
}
func (g *Greeter) Hello(ctx context.Context, req *pb.Request) (rsp *pb.Response, err error) {
rsp.Greeting = "Hello " + req.Name
return rsp, nil
}
func main() {
listener, err := net.Listen("tcp", ":9990")
s := grpc.NewServer()
pb.RegisterGreeterServer(s, &Greeter{})
if err = s.Serve(listener); err != nil {
log.Fatalf("failed to server: %v", err)
}
}
至此,我们就生成了一个GRPC服务,该服务提供了Hello方法。
go-micro与go-gateway
上述代码是使用原生的生成GRPC服务器的方法。接下来我们使用一个目前微服务领域比较流行的框架go-micro来实现GRPC服务器。
相比原生的方式,go-micro拥有更丰富的生态和功能,更方便的工具和API。例如,在go-micro中,服务注册可以方便地切换到etcd、zookeeper、gossip等注册中心,方便开发者实现服务注册功能。server端同时支持grpc、http等协议。
要在go-micro中实现grpc服务器,同样需要利用前面地proto文件生成的协议文件。不过go-micro在此基础上进行了扩展,需要下载protoc-gen-micro插件来生成micro适用的协议文件。这个插件的版本需要和go-micro的版本相同。目前,最新的go-micro版本为v4。
clike
go install github.com/asim/go-micro/cmd/protoc-gen-micro/v4@latest
接着输入如下命名,生成一个新的文件hello.pb.micro.go
clike
protoc -I . --micro_out=. --go_out=. --go-grpc_out=. hello.proto
在hello.pb.micro.go中,micro生成了一个接口GreeterHandler,所以我们需要在代码中实现这个新的接口。

clike
package main
import (
"context"
pb "crawler/proto/greeter/proto/greeter"
"go-micro.dev/v4"
"log"
)
type Greeter1 struct {
}
func (g *Greeter1) Hello(ctx context.Context, req *pb.Request, rsp *pb.Response) (err error) {
rsp.Greeting = "Hello " + req.Name
return nil
}
func main() {
service := micro.NewService(
micro.Name("helloworld"),
)
service.Init()
pb.RegisterGreeterHandler(service.Server(), new(Greeter1))
if err := service.Run(); err != nil {
log.Fatal(err)
}
}
GRPC的调试比http要繁琐,有些外部服务可能不支持适用GRPC,为了解决这些问题,可以让服务同时具备GRPC和HTTP的能力。
要实现这一目标,需要借助一个第三方库------grpc-gateway。grpc-gateway的功能就是生成一个http的代理服务,这个http代理服务会将http请求转换为GRPC协议,并转发到GRPC服务器中。这样,服务便能同时提供http接口和grpc接口。
-
修改.proto文件,引入依赖"google/api/annotations.proto",并且加入了自定义的option选项,grpc-gateway插件会识别这个自定义选项,并为我们生成http代理服务。
gosyntax = "proto3"; option go_package="proto/greeter"; import "google/api/annotations.proto"; service Greeter{ rpc Hello(Request) returns (Response){ option(google.api.http)={ post :"/greeter/hello" body:"*" }; } } message Request{ string name = 1; } message Response{ string greeting=2; } -
安装grpc-gateway插件。
gogo install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest同时,提前下载依赖文件google/api/annotations.proto。这里,我们手动下载依赖文件并放入GOPATH中。
gogit clone https://github.com/googleapis/googleapis mv googleapis/google $(go env GOPATH)/src/google最后,使用下面的指令将proto文件生成协议文件。注意,这里同时加入了go-micro插件和grpc-gateway插件,两个插件之间可能存在命名冲突,所以指定了grpc-gateway的选项register_func_suffix为Gw,它能够让生成的函数名包含该Gw前缀,解决命名冲突的问题。
goprotoc -I $env:GOPATH/src -I $env:GOPATH/src/google/api -I . --micro_out=. --go_out=. --go-grpc_out=. --grpc-gateway_out=logtostderr=true,register_func_suffix=Gw:. hello.proto这样就生成了4个文件,分别是hello.pb.go、hello.pb.gw.go、hello.pb.micro.go、hello_grpc.pb.go。其中,hello.pb.gw.go为grpc-gateway插件生成的文件。
go
package main
import (
"context"
"crawler/log"
pb "crawler/proto/greeter/proto/greeter"
"fmt"
gs "github.com/go-micro/plugins/v4/server/grpc"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"go-micro.dev/v4"
"go-micro.dev/v4/server"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"google.golang.org/grpc"
"net/http"
)
type Greeter1 struct {
}
func (g *Greeter1) Hello(ctx context.Context, req *pb.Request, rsp *pb.Response) (err error) {
rsp.Greeting = "Hello " + req.Name
return nil
}
func main() {
plugin := log.NewStdoutPlugin(zapcore.DebugLevel)
logger := log.NewLogger(plugin)
logger.Info("log init end")
// set zap global logger
zap.ReplaceGlobals(logger)
go HandleHTTP2()
service := micro.NewService(
micro.Name("go.micro.server.worker"),
micro.Server(gs.NewServer()),
micro.Address(":9090"),
micro.WrapHandler(logWrapper(logger)),
)
service.Init()
pb.RegisterGreeterHandler(service.Server(), new(Greeter1))
if err := service.Run(); err != nil {
logger.Error("========")
}
}
func logWrapper(log *zap.Logger) server.HandlerWrapper {
return func(handlerFunc server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
log.Info("recieve request", zap.String("method", req.Method()),
zap.String("Service", req.Service()),
zap.Reflect("request param:", req.Body()))
err := handlerFunc(ctx, req, rsp)
return err
}
}
}
func HandleHTTP2() {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithInsecure()}
err := pb.RegisterGreeterGwFromEndpoint(ctx, mux, "localhost:9090", opts)
if err != nil {
fmt.Println(err)
}
http.ListenAndServe(":8080", mux)
}
接下来,使用http服务服务:

注册中心与etcd
部署etcd服务器,笔者使用docker来部署etcd服务器,docker-compose如下:
go
version: "3.8"
services:
etcd:
image: bitnami/etcd:3.4.24
volumes:
- /tmp/etcd:/etcd-data
ports:
- '2379:2379'
- '2380:2380'
expose:
- 2379
- 2380
networks:
counter-net:
environment:
- ETCDCTL_API=3
# ⚠️ 注意:如果你希望你的 etcd 以单节点运行,需要调整这些参数。
# Bitnami 镜像会使用这些环境变量来构建启动命令。
- ETCD_NAME=etcd
- ETCD_DATA_DIR=/etcd-data
- ETCD_INITIAL_ADVERTISE_PEER_URLS=http://0.0.0.0:2380
- ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
- ETCD_ADVERTISE_CLIENT_URLS=http://0.0.0.0:2379
- ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
- ETCD_INITIAL_CLUSTER=etcd=http://0.0.0.0:2380
- ETCD_INITIAL_CLUSTER_STATE=new
- ETCD_INITIAL_CLUSTER_TOKEN=tkn
- ALLOW_NONE_AUTHENTICATION=yes
# healthcheck:
# # ⚠️ 最好使用 /opt/bitnami/scripts/etcd/healthcheck.sh 或 /opt/bitnami/etcd/bin/etcdctl
# # 这里先保留您的配置,但请注意如果失败可能需要调整路径
# test: ["CMD", "/usr/local/bin/etcdctl" ,"get", "--prefix", "/"]
# interval: 5s
# timeout: 5s
# retries: 55
networks:
counter-net:
driver: bridge
go
package main
import (
"context"
"crawler/log"
pb "crawler/proto/greeter/proto/greeter"
"fmt"
etcdReg "github.com/go-micro/plugins/v4/registry/etcd"
gs "github.com/go-micro/plugins/v4/server/grpc"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"go-micro.dev/v4"
"go-micro.dev/v4/registry"
"go-micro.dev/v4/server"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"google.golang.org/grpc"
"net/http"
)
type Greeter1 struct {
}
func (g *Greeter1) Hello(ctx context.Context, req *pb.Request, rsp *pb.Response) (err error) {
rsp.Greeting = "Hello " + req.Name
return nil
}
func main() {
plugin := log.NewStdoutPlugin(zapcore.DebugLevel)
logger := log.NewLogger(plugin)
logger.Info("log init end")
//注册etcd服务器
reg := etcdReg.NewRegistry(registry.Addrs("8.134.171.198:2379"))
// set zap global logger
zap.ReplaceGlobals(logger)
go HandleHTTP2()
service := micro.NewService(
micro.Name("go.micro.server"),
micro.Server(gs.NewServer()),
micro.Address(":9090"),
micro.Registry(reg),
micro.WrapHandler(logWrapper(logger)),
)
service.Init()
pb.RegisterGreeterHandler(service.Server(), new(Greeter1))
if err := service.Run(); err != nil {
logger.Error("========")
}
}
func logWrapper(log *zap.Logger) server.HandlerWrapper {
return func(handlerFunc server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
log.Info("recieve request", zap.String("method", req.Method()),
zap.String("Service", req.Service()),
zap.Reflect("request param:", req.Body()))
err := handlerFunc(ctx, req, rsp)
return err
}
}
}
func HandleHTTP2() {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithInsecure()}
err := pb.RegisterGreeterGwFromEndpoint(ctx, mux, "localhost:9090", opts)
if err != nil {
fmt.Println(err)
}
http.ListenAndServe(":8080", mux)
}
然后我们先执行上述rpc服务,然后创建一个rpc客户端,访问rpc服务暴露出来的Hello方法。
go
package main
import (
"context"
pb "crawler/proto/greeter/proto/greeter"
"fmt"
"github.com/go-micro/plugins/v4/client/grpc"
"github.com/go-micro/plugins/v4/registry/etcd"
"go-micro.dev/v4"
"go-micro.dev/v4/registry"
)
func main() {
reg := etcd.NewRegistry(registry.Addrs("8.134.171.198:2379"))
service := micro.NewService(
micro.Registry(reg),
micro.Client(grpc.NewClient()))
service.Init()
cl := pb.NewGreeterService("go.micro.server", service.Client())
rsp, _ := cl.Hello(context.Background(), &pb.Request{
Name: "John",
})
fmt.Sprintf("rsp is %v,", rsp.String())
}
执行结果如下:
