在微服务架构中,服务实例的地址和状态会随扩缩容、故障重启频繁变化,通过服务注册与发现机制动态管理服务实例,能让服务消费者无需硬编码实例地址即可完成调用。本文以etcd作为注册中心,讲解Golang中实现服务注册与发现的全流程。

核心概念说明
服务注册指微服务实例启动时,主动向注册中心上报自身的服务名、IP、端口、健康检查地址等元数据;服务发现指服务消费者从注册中心查询指定服务的可用实例列表,结合负载均衡策略发起调用。动态管理则包含实例心跳续期、异常实例自动剔除、实例上下线实时通知等能力。
环境准备
首先需要安装etcd客户端库,执行以下命令引入依赖:
go get go.etcd.io/etcd/client/v3
服务注册实现
服务注册的核心逻辑是向etcd写入带租约的键值对,租约过期时间对应心跳间隔,实例需要定期续租保证注册信息有效。
注册客户端初始化
先创建etcd客户端连接,配置基本的连接参数:
package main
import (
"context"
"fmt"
"time"
"go.etcd.io/etcd/client/v3"
)
// 定义服务注册结构体
type ServiceRegister struct {
cli *clientv3.Client // etcd客户端
leaseID clientv3.LeaseID // 租约ID
leaseTTL int64 // 租约有效期,单位秒
serviceKey string // 服务注册的键
serviceVal string // 服务注册的值,通常是实例地址
}
// 初始化服务注册实例
func NewServiceRegister(endpoints []string, serviceName, instanceAddr string, ttl int64) (*ServiceRegister, error) {
// 创建etcd客户端
cli, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("创建etcd客户端失败: %v", err)
}
// 拼接服务注册的键,格式为 /services/服务名/实例地址
serviceKey := fmt.Sprintf("/services/%s/%s", serviceName, instanceAddr)
return &ServiceRegister{
cli: cli,
leaseTTL: ttl,
serviceKey: serviceKey,
serviceVal: instanceAddr,
}, nil
}
注册与心跳续期
通过创建租约、绑定键值对、定期续租三步完成注册逻辑:
// 执行服务注册
func (r *ServiceRegister) Register() error {
// 创建租约
leaseResp, err := r.cli.Grant(context.Background(), r.leaseTTL)
if err != nil {
return fmt.Errorf("创建租约失败: %v", err)
}
r.leaseID = leaseResp.ID
// 写入带租约的键值对
_, err = r.cli.Put(context.Background(), r.serviceKey, r.serviceVal, clientv3.WithLease(r.leaseID))
if err != nil {
return fmt.Errorf("写入注册信息失败: %v", err)
}
// 启动心跳续期协程
go r.keepAlive()
fmt.Printf("服务注册成功,键: %s, 值: %sn", r.serviceKey, r.serviceVal)
return nil
}
// 心跳续期逻辑
func (r *ServiceRegister) keepAlive() {
// 创建租约续期通道
keepAliveChan, err := r.cli.KeepAlive(context.Background(), r.leaseID)
if err != nil {
fmt.Printf("启动心跳续期失败: %vn", err)
return
}
for {
select {
case resp := <-keepAliveChan:
if resp == nil {
// 续期通道关闭,说明租约已过期
fmt.Println("租约已过期,重新注册")
r.Register()
return
}
}
}
}
// 服务下线时注销
func (r *ServiceRegister) UnRegister() error {
// 撤销租约,etcd会自动删除对应的键值对
_, err := r.cli.Revoke(context.Background(), r.leaseID)
if err != nil {
return fmt.Errorf("撤销租约失败: %v", err)
}
r.cli.Close()
fmt.Println("服务注销成功")
return nil
}
服务发现实现
服务发现需要查询指定服务的所有实例,同时监听实例上下线事件,动态更新本地实例列表。
实例查询
通过etcd的前缀查询能力获取指定服务的所有可用实例:
package main
import (
"context"
"fmt"
"time"
"go.etcd.io/etcd/client/v3"
)
// 定义服务发现结构体
type ServiceDiscovery struct {
cli *clientv3.Client
serviceMap map[string][]string // 缓存的服务实例列表,key为服务名
}
// 初始化服务发现实例
func NewServiceDiscovery(endpoints []string) (*ServiceDiscovery, error) {
cli, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("创建etcd客户端失败: %v", err)
}
return &ServiceDiscovery{
cli: cli,
serviceMap: make(map[string][]string),
}, nil
}
// 查询指定服务的所有实例
func (d *ServiceDiscovery) GetInstances(serviceName string) ([]string, error) {
// 前缀查询,匹配 /services/服务名/ 开头的所有键
prefix := fmt.Sprintf("/services/%s/", serviceName)
resp, err := d.cli.Get(context.Background(), prefix, clientv3.WithPrefix())
if err != nil {
return nil, fmt.Errorf("查询服务实例失败: %v", err)
}
var instances []string
for _, kv := range resp.Kvs {
instances = append(instances, string(kv.Value))
}
// 更新本地缓存
d.serviceMap[serviceName] = instances
return instances, nil
}
实例变更监听
通过etcd的Watch机制监听服务实例的上下线事件,实时更新本地缓存:
// 监听服务实例变更
func (d *ServiceDiscovery) WatchService(serviceName string) {
prefix := fmt.Sprintf("/services/%s/", serviceName)
// 创建监听通道
watchChan := d.cli.Watch(context.Background(), prefix, clientv3.WithPrefix())
go func() {
for watchResp := range watchChan {
for _, event := range watchResp.Events {
instanceAddr := string(event.Kv.Value)
switch event.Type {
case clientv3.EventTypePut:
// 实例上线或更新
fmt.Printf("服务实例上线: %sn", instanceAddr)
d.addInstance(serviceName, instanceAddr)
case clientv3.EventTypeDelete:
// 实例下线
fmt.Printf("服务实例下线: %sn", instanceAddr)
d.removeInstance(serviceName, instanceAddr)
}
}
}
}()
}
// 添加实例到缓存
func (d *ServiceDiscovery) addInstance(serviceName, instanceAddr string) {
instances, ok := d.serviceMap[serviceName]
if !ok {
d.serviceMap[serviceName] = []string{instanceAddr}
return
}
// 去重判断
for _, addr := range instances {
if addr == instanceAddr {
return
}
}
d.serviceMap[serviceName] = append(instances, instanceAddr)
}
// 从缓存移除实例
func (d *ServiceDiscovery) removeInstance(serviceName, instanceAddr string) {
instances, ok := d.serviceMap[serviceName]
if !ok {
return
}
var newInstances []string
for _, addr := range instances {
if addr != instanceAddr {
newInstances = append(newInstances, addr)
}
}
d.serviceMap[serviceName] = newInstances
}
完整使用示例
以下是服务注册和服务发现模块的联合使用示例:
func main() {
// etcd集群地址,实际场景可配置多个节点
endpoints := []string{"127.0.0.1:2379"}
// 服务注册示例
register, err := NewServiceRegister(endpoints, "user-service", "192.168.0.1:8080", 10)
if err != nil {
panic(err)
}
err = register.Register()
if err != nil {
panic(err)
}
defer register.UnRegister()
// 服务发现示例
discovery, err := NewServiceDiscovery(endpoints)
if err != nil {
panic(err)
}
// 先查询一次现有实例
instances, err := discovery.GetInstances("user-service")
if err != nil {
panic(err)
}
fmt.Printf("初始实例列表: %vn", instances)
// 启动监听
discovery.WatchService("user-service")
// 模拟服务运行
time.Sleep(30 * time.Second)
}
注意事项
- etcd集群需要保证高可用,避免单点故障导致注册发现功能失效
- 租约TTL需要根据实例心跳间隔合理设置,通常设置为心跳间隔的2-3倍,避免网络波动导致误判实例下线
- 本地实例缓存需要加锁保护,避免并发读写导致数据异常
- 服务消费者调用实例时,需要结合重试、熔断等机制,提升调用稳定性