go-zero使用etcd实现服务注册与发现
-
- 前言:我们要解决什么问题?
- 第一部分:概念解释(目标)
-
- [1. 什么是服务注册?](#1. 什么是服务注册?)
- [2. 什么是服务发现?](#2. 什么是服务发现?)
- [3. 什么是负载均衡?](#3. 什么是负载均衡?)
- 第二部分:实际操作(跟着我一步步来)
- 第三部分:原理剖析(这背后到底发生了什么?)
- 第四部分:本质思考(这到底解决了什么问题?)
- 第五部分:总结
- 第六部分:常见问题
- 附录:关键核心代码
- 复盘:
关键词:服务注册与发现、Etcd、Go-Zero、负载均衡、微服务等关键概念

前言:我们要解决什么问题?
假设你开了一家连锁餐厅,有两家分店:
- 分店A:地址在"人民路1号"
- 分店B:地址在"解放路2号"
顾客想吃你家菜,怎么知道去哪家店?
传统做法:顾客拿个小本本,记下两家店的地址,自己决定去哪家。
问题来了:
- 如果你开了第三家分店,顾客不知道
- 如果分店A倒闭了,顾客还往那里跑
- 顾客不知道哪家店人少、哪家店人多
聪明的做法:搞个"订餐热线",顾客打电话,系统自动告诉他:
- 现在有2家店营业
- 分店A现在空闲,建议去分店A
- 如果分店B突然关门了,系统自动更新
这就是服务注册与发现!
第一部分:概念解释(目标)
1. 什么是服务注册?
服务注册 = 服务启动时,告诉注册中心:"我开业了,地址是XXX"
就像分店开业时,向总部登记:
- "我是分店A,地址人民路1号,开始营业"
2. 什么是服务发现?
服务发现 = 客户想调用服务时,问注册中心:"哪家店开门?地址多少?"
就像顾客打电话问总部:
- "我想吃饭,哪家店开门?"
- 总部回复:"现在有分店A(人民路1号)和分店B(解放路2号),分店A人少,建议去分店A"
3. 什么是负载均衡?
负载均衡 = 系统自动帮你选一个不忙的服务
就像总部不仅告诉你地址,还帮你参谋:
- "分店A现在只有1桌客人,去那边吧"
- "分店B排队都排到门口了,别去了"
第二部分:实际操作(跟着我一步步来)
准备工作
你需要准备的东西:
- 一台电脑(Linux/Mac/Windows都行)
- 安装好Go语言(1.20以上版本)
- 一个能用的终端
步骤1:安装Etcd(注册中心)
Etcd就是我们的"订餐热线",所有服务都往这里登记。
bash
# 下载 Etcd
wget https://github.com/etcd-io/etcd/releases/download/v3.5.12/etcd-v3.5.12-linux-amd64.tar.gz
# 解压
tar -zxvf etcd-v3.5.12-linux-amd64.tar.gz
cd etcd-v3.5.12-linux-amd64
# 移动到系统目录(方便随时调用)
sudo mv etcd etcdctl /usr/local/bin/
# 验证安装成功
etcd --version
# 输出类似:etcd Version: 3.5.12 ...
# 启动 Etcd(保持这个终端开着,不要关)
etcd
# 看到类似输出就成功了
# {"level":"info","msg":"started","time":"2024-01-01T10:00:00Z"}
此时发生了什么?
- Etcd启动成功,监听2379端口
- 等待服务来注册
步骤2:创建项目
bash
# 创建一个叫"user"的RPC项目
goctl rpc new user
# 进入项目目录
cd user
# 初始化Go模块依赖
go mod tidy
项目结构长这样:
user/
├── etc/
│ └── user.yaml # 配置文件(关键)
├── internal/
│ ├── config/ # 配置定义
│ ├── logic/ # 业务逻辑
│ ├── server/ # RPC服务实现
│ └── svc/ # 服务上下文
├── user/ # 生成的proto代码
├── user.proto # 接口定义
├── user.go # 服务入口
└── client_discovery.go # 客户端(我们待会儿写)
步骤3:配置服务端(8080端口)
打开 etc/user.yaml,内容如下:
yaml
Name: user.rpc # 服务名称
ListenOn: 0.0.0.0:8080 # 监听8080端口
Etcd: # 服务注册配置
Hosts:
- 127.0.0.1:2379 # Etcd地址
Key: user.rpc # 注册的Key(重要!)
这个配置的意思是:
- 我的服务叫"user.rpc"
- 我监听在8080端口
- 我要向Etcd(127.0.0.1:2379)注册自己
- 注册的Key是"user.rpc"
步骤4:创建第二个服务端(8081端口)
为了演示负载均衡,我们需要启动两个服务实例。
bash
# 复制配置文件
cp etc/user.yaml etc/user-8081.yaml
# 编辑新配置文件
# 把 ListenOn 改成 8081
# 其他保持不变(特别是 Etcd.Key 要一样)
编辑 etc/user-8081.yaml:
yaml
Name: user.rpc
ListenOn: 0.0.0.0:8081 # 改成8081端口
Etcd:
Hosts:
- 127.0.0.1:2379
Key: user.rpc # Key必须和上面一样!
为什么Key要一样?
- 就像两家分店都属于同一个品牌
- 客户通过品牌名找到所有分店
- Key就是这个"品牌名"
步骤5:启动两个服务端
打开三个终端窗口:
- 终端1:Etcd(已经启动)
- 终端2:服务端8080
- 终端3:服务端8081
终端2(启动8080服务):
bash
cd ~/go-zero-learn/grpc-demo/user
go run user.go -f etc/user.yaml
# 看到这个就成功了:
# Starting rpc server at 0.0.0.0:8080...
终端3(启动8081服务):
bash
cd ~/go-zero-learn/grpc-demo/user
go run user.go -f etc/user-8081.yaml
# 看到这个就成功了:
# Starting rpc server at 0.0.0.0:8081...
此时发生了什么?
两个服务都向Etcd注册了自己:
Etcd里的数据:
┌─────────────────────────────────────────────┐
│ Key: user.rpc │
│ │
│ 服务列表: │
│ - 172.29.206.73:8080 (分店A) │
│ - 172.29.206.73:8081 (分店B) │
└─────────────────────────────────────────────┘
你可以用etcdctl验证:
bash
etcdctl get user.rpc --prefix
# 输出类似:
# user.rpc/172.29.206.73:8080
# {"Host":"172.29.206.73","Port":8080}
# user.rpc/172.29.206.73:8081
# {"Host":"172.29.206.73","Port":8081}
步骤6:创建客户端
创建 client_discovery.go 文件:
go
package main
import (
"context"
"log"
"user/userclient"
"github.com/zeromicro/go-zero/core/discov"
"github.com/zeromicro/go-zero/zrpc"
)
func main() {
// 配置服务发现
clientConf := zrpc.RpcClientConf{
Etcd: discov.EtcdConf{
Hosts: []string{"127.0.0.1:2379"}, // Etcd地址
Key: "user.rpc", // 服务Key
},
Timeout: 3000,
}
// 创建客户端(自动从Etcd发现服务)
conn := zrpc.MustNewClient(clientConf)
client := userclient.NewUser(conn)
// 调用服务
req := &userclient.Request{
Ping: "go-zero",
}
resp, err := client.Ping(context.Background(), req)
if err != nil {
log.Println(err)
return
}
log.Println(resp)
}
这段代码做了什么?
- 告诉客户端:"去Etcd查询user.rpc这个服务"
- Etcd返回:"有两个地址:8080和8081"
- 客户端:"我选一个连接"
- 调用Ping方法
- 打印结果
步骤7:运行客户端,看效果!
bash
# 在第四个终端运行
cd ~/go-zero-learn/grpc-demo/user
go run client_discovery.go
第一次运行:
json
{"@timestamp":"2026-07-19T16:23:24.265+08:00","caller":"p2c/p2c.go:195","content":"p2c - conn: 172.29.206.73:8080, load: 1, reqs: 0; conn: 172.29.206.73:8081, load: 1046, reqs: 1","level":"stat"}
2026/07/19 16:23:24 pong:"go-zero"
解读:
- 系统发现两个服务:8080(负载=1)和8081(负载=1046)
- 这次选中了8081(虽然它更忙,但P2C算法就是这样)
- 返回"pong:go-zero"
第二次运行:
json
{"@timestamp":"2026-07-19T16:23:25.959+08:00","caller":"p2c/p2c.go:195","content":"p2c - conn: 172.29.206.73:8081, load: 969, reqs: 1","level":"stat"}
2026/07/19 16:23:25 pong:"go-zero"
这次又选中了8081。
第三次运行:
json
{"@timestamp":"2026-07-19T16:23:27.641+08:00","caller":"p2c/p2c.go:195","content":"p2c - conn: 172.29.206.73:8080, load: 1, reqs: 0; conn: 172.29.206.73:8081, load: 985, reqs: 1","level":"stat"}
2026/07/19 16:23:27 pong:"go-zero"
这次选中了8080!
看到规律了吗?
- 有时访问8080,有时访问8081
- 系统自动帮你做负载均衡
- 你不需要知道具体地址,只要知道Key="user.rpc"
第三部分:原理剖析(这背后到底发生了什么?)
整体流程图
┌────────────────────────────────────────────────────────────────┐
│ 完整流程时序图 │
│ │
│ 第一步:服务注册 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 服务端 8080 │ │ 服务端 8081 │ │
│ │ │ │ │ │
│ │ 启动时... │ │ 启动时... │ │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │ 向Etcd注册 │ 向Etcd注册 │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Etcd │ │
│ │ │ │
│ │ 存储的服务列表: │ │
│ │ user.rpc → [172.29.206.73:8080, 172.29.206.73:8081]│ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 第二步:服务发现 │
│ ┌──────────────┐ │
│ │ 客户端 │ │
│ │ │ │
│ │ 启动时... │ │
│ └──────────────┘ │
│ │ │
│ │ 1. 查询Etcd:"user.rpc有哪些服务?" │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Etcd │ │
│ │ │ │
│ │ 返回:[172.29.206.73:8080, 172.29.206.73:8081] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ │ 2. 收到服务列表 │
│ ▼ │
│ ┌──────────────┐ │
│ │ 客户端 │ │
│ │ │ │
│ │ 负载均衡... │ │
│ │ 选择8080 │ │
│ │ 或8081 │ │
│ └──────────────┘ │
│ │ │
│ │ 3. 连接选中的服务 │
│ ▼ │
│ ┌──────────────┐ 或 ┌──────────────┐ │
│ │ 服务端 8080 │ │ 服务端 8081 │ │
│ │ │ │ │ │
│ │ 处理请求 │ │ 处理请求 │ │
│ └──────────────┘ └──────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
关键组件说明
1. Etcd(注册中心)
作用: 存储"服务名 → 地址列表"的映射
数据结构:
Key: user.rpc
Value: [
{"Host":"172.29.206.73","Port":8080},
{"Host":"172.29.206.73","Port":8081}
]
关键特性:
- 高可用:可以搭建集群
- 实时性:服务启动立即注册
- 健康检查:服务挂了自动剔除
2. 服务端(Server)
做了什么:
go
// 1. 加载配置
var c config.Config
conf.MustLoad(*configFile, &c)
// 2. 创建服务
s := zrpc.MustNewServer(c.RpcServerConf, ...)
// 内部自动执行:
// - 连接Etcd
// - 注册服务(Key=user.rpc, Value=IP:Port)
// - 启动心跳(每5秒续约一次)
3. 客户端(Client)
做了什么:
go
// 1. 配置服务发现
clientConf := zrpc.RpcClientConf{
Etcd: discov.EtcdConf{
Hosts: []string{"127.0.0.1:2379"},
Key: "user.rpc",
},
}
// 2. 创建连接(自动服务发现)
conn := zrpc.MustNewClient(clientConf)
// 内部自动执行:
// - 连接Etcd
// - 查询Key=user.rpc的服务列表
// - 启动监听(服务变化自动更新)
// - 负载均衡选择一个实例
4. P2C负载均衡算法
P2C = Pick of 2 Choices(二选一)
原理:
┌─────────────────────────────────────────────────────────────┐
│ P2C负载均衡算法 │
│ │
│ 服务列表:[8080, 8081] │
│ │
│ 第一步:随机选两个 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 8080 │ │ 8081 │ │
│ │ load: 100 │ │ load: 500 │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ 第二步:比较负载 │
│ 100 < 500,选负载低的 │
│ │
│ 第三步:选中 8080 │
│ │
│ 优点: │
│ - 简单高效 │
│ - 无需知道全局负载 │
│ - 自动适应后端变化 │
│ │
└─────────────────────────────────────────────────────────────┘
日志解读
json
{
"@timestamp": "2026-07-19T16:23:24.265+08:00",
"caller": "p2c/p2c.go:195",
"content": "p2c - conn: 172.29.206.73:8080, load: 1, reqs: 0; conn: 172.29.206.73:8081, load: 1046, reqs: 1",
"level": "stat"
}
逐字段解读:
timestamp:时间戳caller:日志来源(p2c.go第195行)content:负载情况- 8080: load=1(负载很低),reqs=0(当前请求数)
- 8081: load=1046(负载较高),reqs=1
level:日志级别(stat=统计信息)
这行日志的意思是:
"我发现两个服务:8080很闲,8081有点忙。我选择了XXX。"
第四部分:本质思考(这到底解决了什么问题?)
问题一:为什么需要服务注册与发现?
传统方式(硬编码):
go
// 客户端写死地址
conn, _ := grpc.Dial("192.168.1.100:8080", ...)
问题:
- 地址变了,要改代码重新编译
- 无法动态扩容
- 服务挂了,客户端还往那里发请求
- 多个实例,无法自动负载均衡
服务发现方式:
go
// 客户端只关心服务名
clientConf := zrpc.RpcClientConf{
Etcd: discov.EtcdConf{
Key: "user.rpc", // 只需要服务名
},
}
优势:
- 地址变了,Etcd自动更新,客户端无感知
- 新增服务实例,自动注册,客户端自动发现
- 服务挂了,Etcd自动剔除,客户端自动切换
- 多实例自动负载均衡
问题二:负载均衡的意义是什么?
没有负载均衡:
请求1 → 8080
请求2 → 8080
请求3 → 8080
...
请求1000 → 8080 (8080累死)
8081一直空闲 (8081闲死)
有负载均衡(P2C):
请求1 → 8081(8080空闲,但随机选中8081)
请求2 → 8081
请求3 → 8080(发现8081忙,选8080)
请求4 → 8080
...
最终:8080处理500个,8081处理500个(均衡)
问题三:这和微服务有什么关系?
微服务的核心:服务拆分
单体应用:
┌──────────────────────┐
│ 一个大应用 │
│ ┌──────────────┐ │
│ │ 用户模块 │ │
│ │ 订单模块 │ │
│ │ 支付模块 │ │
│ └──────────────┘ │
└──────────────────────┘
微服务:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 用户服务 │ │ 订单服务 │ │ 支付服务 │
│ 8080 │ │ 8081 │ │ 8082 │
└──────────┘ └──────────┘ └──────────┘
↑ ↑ ↑
│ │ │
└──────────────┴──────────────┘
│
┌─────────┐
│ Etcd │
└─────────┘
微服务需要解决的问题:
- 服务之间怎么找到对方?→ 服务发现
- 多个实例选哪个?→ 负载均衡
- 服务挂了怎么办?→ 健康检查
Go-Zero的RPC就是解决这些问题的工具!
第五部分:总结
核心概念总结
| 概念 | 作用 | 比喻 |
|---|---|---|
| Etcd | 注册中心 | 订餐热线 |
| 服务注册 | 服务启动时登记 | 分店开业登记 |
| 服务发现 | 查找可用服务 | 顾客打电话查询 |
| 负载均衡 | 自动选择实例 | 推荐人少的分店 |
| P2C算法 | 负载均衡策略 | 比较两家店,选人少的 |
关键配置总结
服务端配置:
yaml
Etcd:
Hosts: [127.0.0.1:2379] # 注册中心地址
Key: user.rpc # 服务标识(重要!)
客户端配置:
go
Etcd: discov.EtcdConf{
Hosts: []string{"127.0.0.1:2379"},
Key: "user.rpc", // 必须和服务端一致!
}
关键点:Key必须一致!
- 就像品牌名要一致
- 客户端通过Key找服务端
- Key错了,找不到服务
操作步骤总结
第一步:启动注册中心
├── etcd
│
第二步:启动服务端(可多个)
├── go run user.go -f etc/user.yaml (8080)
├── go run user.go -f etc/user-8081.yaml (8081)
│
第三步:运行客户端
└── go run client_discovery.go
├── 自动从Etcd发现服务
├── 自动负载均衡
└── 返回结果
本质总结
服务注册与发现的本质:
把"地址硬编码"变成"名字查找",把"手动管理"变成"自动管理"。
负载均衡的本质:
把"请求均匀分布到多个实例",避免"忙的忙死,闲的闲死"。
微服务通信的本质:
通过"服务名"而不是"地址"来通信,实现解耦。
第六部分:常见问题
Q1:如果我启动10个服务实例会怎样?
答: 都会注册到Etcd,客户端自动发现10个实例,负载均衡在10个实例之间进行。
Q2:如果某个服务挂了会怎样?
答:
- Etcd检测到心跳失败
- 自动剔除该实例
- 客户端自动感知更新
- 后续请求不会发到挂掉的服务
Q3:Etcd挂了会怎样?
答:
- 服务无法注册,新服务启动失败
- 已注册的服务继续工作
- 客户端可以使用缓存的地址列表
- 生产环境建议Etcd集群(3-5个节点)
Q4:为什么有时选中负载高的实例?
答: P2C算法是"二选一",随机选2个,比较后选负载低的。如果恰好随机选了两个负载都高的,就会选中负载高的。但长期来看,负载会趋于均衡。
Q5:可以在不同机器上部署服务吗?
答: 可以!
- 服务A在机器192.168.1.100:8080
- 服务B在机器192.168.1.101:8080
- 客户端自动发现,自动负载均衡
- 这就是微服务架构的基础!
附录:关键核心代码
服务端配置(etc/user.yaml)
yaml
Name: user.rpc
ListenOn: 0.0.0.0:8080
Etcd:
Hosts:
- 127.0.0.1:2379
Key: user.rpc
客户端代码(client_discovery.go)
go
package main
import (
"context"
"log"
"user/userclient"
"github.com/zeromicro/go-zero/core/discov"
"github.com/zeromicro/go-zero/zrpc"
)
func main() {
clientConf := zrpc.RpcClientConf{
Etcd: discov.EtcdConf{
Hosts: []string{"127.0.0.1:2379"},
Key: "user.rpc",
},
Timeout: 3000,
}
conn := zrpc.MustNewClient(clientConf)
client := userclient.NewUser(conn)
req := &userclient.Request{
Ping: "go-zero",
}
resp, err := client.Ping(context.Background(), req)
if err != nil {
log.Println(err)
return
}
log.Println(resp)
}
复盘:
本文是一篇使用 Go-Zero 框架结合 Etcd 实现服务注册与发现的实战教程。
文章通过餐厅分店的生动比喻,系统性地讲解了微服务架构中的核心概念和实现方法。
主要内容包括:
- 1)服务注册、服务发现、负载均衡等核心概念解释;
- 2)从安装 Etcd、创建项目、配置服务端到运行客户端的完整操作步骤;
- 3)深入剖析 Etcd 注册中心、服务端、客户端及 P2C 负载均衡算法的工作原理;
- -4)探讨服务注册与发现解决的传统硬编码问题、负载均衡的意义及其与微服务架构的关系;
- 5)总结核心概念、关键配置和操作步骤,并解答常见问题。通过本文,读者可以掌握使用 Go-Zero 和 Etcd 构建可扩展、高可用的微服务系统的基础技能。