
很多同学初学Go微服务时,容易把重点放在框架API调用,却忽略服务通信和注册发现这两大底层基石。单体项目我们直接写死IP地址调用;但微服务集群中,服务实例会动态扩缩容、机器宕机、节点IP随时变化,硬编码地址完全不可行。
我们聚焦落地实战,采用 gRPC 作为服务间通信协议, etcd 作为注册中心,手把手实现服务注册、服务发现、RPC跨服务调用,讲解底层原理、完整代码、生产级注意事项,不堆砌无关概念。适合已经掌握Go基础,准备上手微服务项目的后端开发者。
技术栈:Go1.22 + gRPC + etcd v3
架构简述:
-
服务提供者(user-service):启动后,把自身节点信息注册到etcd,携带租约+心跳保活
-
注册中心etcd:存储服务名称、实例IP端口,租约过期自动剔除故障节点
-
服务消费者(order-service):从etcd监听服务节点变更,动态获取可用实例,发起gRPC调用
-
通信方式:内部服务之间使用gRPC二进制协议,高性能、自带序列化
一、核心概念理解
- 服务通信:为什么微服务内部优先选gRPC?
微服务通信分为两种主流方案:
-
RESTful HTTP:文本JSON,调试方便,但是序列化开销大,性能弱,适合对外网关;
-
gRPC:基于Protobuf,二进制序列化,体积小、传输快,支持流式传输、拦截器,服务内部通信首选。
Protobuf提前定义接口契约,服务端和客户端共用同一份协议文件,避免前后端参数不一致问题。
-
注册发现的工作流程
-
注册:服务启动,连接etcd,创建租约Lease,将 服务名/实例地址 写入etcd;同时开启心跳续租。如果服务进程崩溃,租约到期,etcd自动删除这条记录。
-
发现:消费端连接etcd,根据服务名称查询全部可用节点,并开启Watch监听;一旦节点上下线,etcd实时推送变更,客户端更新本地节点列表。
-
负载均衡:客户端拿到多个实例地址,在本地做轮询、随机等负载均衡,把请求分发到不同节点(客户端LB,gRPC原生支持)。
租约(Lease)是etcd实现健康节点管理的关键,避免服务宕机之后注册中心残留无效节点。
二、项目初始化与环境准备
安装protobuf代码生成工具
bash
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
初始化模块
bash
go mod init go-micro-registry
安装依赖
go get google.golang.org/grpc
go get google.golang.org/protobuf
go get go.etcd.io/etcd/client/v3
目录结构
plaintext
go-micro-registry/
├── proto/
│ └── user.proto # gRPC协议定义
├── registry/
│ └── etcd_reg.go # 封装etcd注册、发现工具
├── user-service/ # 服务提供者
├── order-service/ # 服务消费者
└── go.mod
三、定义Protobuf协议
proto/user.proto
protobuf
syntax = "proto3";
option go_package = "./;pb";
message GetUserReq {
int64 uid = 1;
}
message GetUserResp {
string username = 1;
int32 age = 2;
}
service UserService {
rpc GetUser(GetUserReq) returns(GetUserResp);
}
执行命令生成go代码:
bash
protoc --go_out=. --go-grpc_out=. proto/user.proto
四、封装ETCD注册发现公共模块
registry/etcd_reg.go ,封装注册、心跳、服务发现逻辑
go
package registry
import (
"context"
"encoding/json"
"log"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
)
// ServiceInstance 服务实例信息
type ServiceInstance struct {
ServiceName string `json:"service_name"`
Addr string `json:"addr"`
}
type EtcdRegistry struct {
cli *clientv3.Client
leaseID clientv3.LeaseID
rootPath string
}
func NewEtcdRegistry(endpoints \[\]string, rootPath string) (*EtcdRegistry, error) {
cli, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, err
}
return &EtcdRegistry{
cli: cli,
rootPath: rootPath,
}, nil
}
// Register 注册服务,绑定租约,开启心跳续租
func (r *EtcdRegistry) Register(ctx context.Context, instance ServiceInstance, ttl int64) error {
// 创建租约
leaseResp, err := r.cli.Grant(ctx, ttl)
if err != nil {
return err
}
r.leaseID = leaseResp.ID
val, _ := json.Marshal(instance)
key := r.rootPath + "/" + instance.ServiceName + "/" + instance.Addr
// 将实例信息写入etcd,绑定租约
_, err = r.cli.Put(ctx, key, string(val), clientv3.WithLease(r.leaseID))
if err != nil {
return err
}
// 自动续租心跳
keepAliveChan, err := r.cli.KeepAlive(ctx, r.leaseID)
if err != nil {
return err
}
go func() {
for range keepAliveChan {
// 收到续租成功响应,无需额外逻辑
}
log.Println("租约续约中断,服务实例即将失效")
}()
log.Printf("服务注册成功 key=%s, addr=%s", key, instance.Addr)
return nil
}
// Discover 获取指定服务所有实例
func (r *EtcdRegistry) Discover(ctx context.Context, serviceName string) (\[\]ServiceInstance, error) {
prefix := r.rootPath + "/" + serviceName
resp, err := r.cli.Get(ctx, prefix, clientv3.WithPrefix())
if err != nil {
return nil, err
}
var list \[\]ServiceInstance
for _, kv := range resp.Kvs {
var ins ServiceInstance
_ = json.Unmarshal(kv.Value, &ins)
list = append(list, ins)
}
return list, nil
}
五、编写服务提供者 user-service
user-service 启动时,自动注册到etcd,对外提供gRPC查询用户接口
go
package main
import (
"context"
"log"
"net"
"go-micro-registry/pb"
"go-micro-registry/registry"
)
type UserServer struct {
pb.UnimplementedUserServiceServer
}
func (s *UserServer) GetUser(ctx context.Context, req *pb.GetUserReq) (*pb.GetUserResp, error) {
return &pb.GetUserResp{
Username: "李四",
Age: 28,
}, nil
}
func main() {
listenAddr := "127.0.0.1:8001"
lis, err := net.Listen("tcp", listenAddr)
if err != nil {
log.Fatal(err)
}
// 注册etcd
reg, err := registry.NewEtcdRegistry(\[\]string{"127.0.0.1:2379"}, "/micro")
if err != nil {
log.Fatal(err)
}
ins := registry.ServiceInstance{
ServiceName: "user-service",
Addr: listenAddr,
}
err = reg.Register(context.Background(), ins, 10) //租约10秒
if err != nil {
log.Fatal(err)
}
grpcServer := grpc.NewServer()
pb.RegisterUserServiceServer(grpcServer, &UserServer{})
log.Println("user-service start at", listenAddr)
err = grpcServer.Serve(lis)
if err != nil {
log.Fatal(err)
}
}
六、编写服务消费者 order-service
订单服务,启动后从etcd发现user-service节点,调用gRPC接口
go
package main
import (
"context"
"log"
"go-micro-registry/pb"
"go-micro-registry/registry"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
reg, err := registry.NewEtcdRegistry(\[\]string{"127.0.0.1:2379"}, "/micro")
if err != nil {
log.Fatal(err)
}
// 发现user-service实例
instances, err := reg.Discover(context.Background(), "user-service")
if err != nil {
log.Fatal(err)
}
if len(instances) == 0 {
log.Fatal("没有找到可用user-service实例")
}
target := instances0.Addr
conn, err := grpc.Dial(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
userClient := pb.NewUserServiceClient(conn)
resp, err := userClient.GetUser(context.Background(), &pb.GetUserReq{Uid: 1001})
if err != nil {
log.Fatal(err)
}
log.Printf("调用用户服务成功:name=%s, age=%d", resp.Username, resp.Age)
}
七、联调测试步骤
-
本地启动etcd,默认端口2379
-
启动user-service,日志输出服务注册成功
-
运行order-service,从etcd拉取实例,成功调用gRPC接口
-
测试故障场景:直接kill user-service进程,等待10s租约过期,etcd自动删除该实例;再次执行discover,无法获取节点。
这个demo是基础原型,当前客户端仅一次性查询节点;生产环境需要增加Watch监听,实时感知节点上下线,配合gRPC resolver动态更新地址列表。
八、生产落地关键要点
- 通信层面
-
gRPC连接不要每次请求新建连接,使用连接池,防止连接泄漏;
-
生产环境开启TLS/mTLS加密,避免裸流量传输;
-
增加拦截器,统一处理日志、耗时统计、错误捕获、鉴权。
- 注册中心层面
-
etcd建议集群部署,单机仅用于开发测试;
-
租约TTL不要太短,推荐5~10s,心跳持续续租;
-
增加实例健康检查,即使租约没过期,也要主动探测服务可用性。
- 服务治理配套(必须补齐)
-
负载均衡:多实例场景,客户端LB轮询、权重调度;
-
熔断、降级、重试:防止下游服务故障引发雪崩;
-
链路追踪:Jaeger/Zipkin追踪跨服务调用链路;
-
监控告警:Prometheus采集QPS、错误率、延迟指标。
九、框架选型建议
-
原生gRPC+etcd:自由度最高,适合深度定制,学习成本高,需要自己实现大量治理逻辑;
-
go-zero:国内企业广泛落地,内置etcd/nacos注册发现、熔断限流,开箱即用,业务开发首选;
-
Kitex(CloudWeGo):字节开源,极致高性能RPC,适合高吞吐大流量场景。
十、常见踩坑总结
-
etcd租约失效:服务进程卡死但没有退出,心跳停止,租约到期节点被剔除,要做好进程健康自检。
-
硬编码IP:新手写demo习惯写死地址,集群扩容后完全无法使用,必须依赖注册发现。
-
忽略网络异常:RPC调用存在超时、网络抖动,代码必须设置context超时,不能无限阻塞。
-
注册中心单点故障:etcd单机一旦宕机,全服务无法发现,生产必须搭建etcd集群。
-
过度设计:业务体量小的时候,不需要微服务,单体开发维护成本更低。
服务通信与注册发现是Go微服务最核心的底层骨架。很多框架把这部分能力封装在内部,开发者直接调用就能使用,但读懂底层实现,才能理解微服务的故障模型,线上排查问题时不再无从下手。