Skip to content

服务注册与发现

本篇是 Go 微服务系列的第二篇。我们将深入服务注册与发现这一微服务基础设施的核心主题。在分布式系统中,服务实例的地址会动态变化(容器漂移、扩缩容、节点宕机),调用方不能再写死 IP 地址。本篇将讲解服务发现的原理,并使用 Consul 和 Etcd 完成一个真实的服务注册与发现闭环。

一、为什么需要服务注册与发现

1. 静态配置的痛点

假设有一个订单服务需要调用用户服务,最直接的做法是把用户服务的地址写在配置文件里:

yaml
user_service:
  host: 192.168.1.10
  port: 8080

这种做法在以下场景会立即崩溃:

  • 容器化部署后,每次重启 IP 都可能变化。
  • 用户服务扩容到 5 个实例,配置文件如何维护?
  • 某个实例宕机,调用方如何感知并避开它?
  • 跨机房部署,调用方如何选择最近的实例?

2. 服务发现的核心思路

引入一个注册中心(Registry),所有服务实例启动时把自己的地址注册上去,调用方从注册中心查询目标服务的可用实例列表,并在本地做负载均衡。

   ┌──────────┐  register   ┌──────────────┐
   │ Service A│ ──────────► │   Registry   │
   │ 1.2.3.4  │ ◄────────── │ (Consul/Etcd)│
   └──────────┘   health    └──────┬───────┘
                                       │ query
   ┌──────────┐                        ▼
   │ Service B│ ◄────────────────────────────
   │ 5.6.7.8  │     gets list of A instances
   └──────────┘

3. 客户端发现 vs 服务端发现

客户端发现:调用方直接查询注册中心,自己做负载均衡。代表实现:Netflix Ribbon、go-kit SD。

  • 优点:少一跳网络,无单点;客户端可定制负载均衡策略。
  • 缺点:客户端逻辑复杂,多语言要重复实现。

服务端发现:调用方请求一个固定的路由层(如网关、Service Mesh sidecar),由路由层查询注册中心并转发。代表实现:Kubernetes Service、Envoy。

  • 优点:客户端无感知,多语言友好。
  • 缺点:多一跳网络,路由层需高可用。

本篇聚焦客户端发现模式,使用 Consul 和 Etcd 实战。

二、Consul 实战

1. Consul 简介

Consul 是 HashiCorp 开源的服务网格解决方案,核心能力包括:

  • 服务发现:服务注册、DNS/HTTP 查询。
  • 健康检查:HTTP、TCP、Script、TTL 多种检查方式。
  • KV 存储:分布式键值存储,可用于配置中心。
  • 多数据中心:原生支持多数据中心 federation。
  • Raft 一致性:强一致性的集群协议。

2. 安装 Consul

最简单的方式是使用 Docker:

bash
docker run -d --name consul \
  -p 8500:8500 -p 8600:8600/udp \
  consul:1.15 agent -dev -client=0.0.0.0

启动后访问 http://localhost:8500 即可看到 Consul Web UI。

也可以下载二进制直接运行:

bash
consul agent -dev -client=0.0.0.0

3. 注册服务(HTTP API)

Consul 提供了 HTTP API,可以直接注册服务:

bash
curl -X PUT http://localhost:8500/v1/agent/service/register -d '{
  "Name": "user-service",
  "ID": "user-1",
  "Address": "127.0.0.1",
  "Port": 8080,
  "Check": {
    "HTTP": "http://127.0.0.1:8080/health",
    "Interval": "10s"
  }
}'

4. Go SDK 注册服务

实际项目里我们通常使用官方 Go SDK:github.com/hashicorp/consul/api

下面是一个完整的服务端注册示例:

go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/hashicorp/consul/api"
)

// ConsulRegistrar 封装 Consul 注册逻辑
type ConsulRegistrar struct {
	client *api.Client
	id     string
}

func NewConsulRegistrar(addr string) (*ConsulRegistrar, error) {
	cfg := api.DefaultConfig()
	cfg.Address = addr
	client, err := api.NewClient(cfg)
	if err != nil {
		return nil, err
	}
	return &ConsulRegistrar{client: client}, nil
}

func (r *ConsulRegistrar) Register(serviceName, serviceID, address string, port int) error {
	r.id = serviceID
	reg := &api.AgentServiceRegistration{
		ID:      serviceID,
		Name:    serviceName,
		Address: address,
		Port:    port,
		Check: &api.AgentServiceCheck{
			HTTP:     fmt.Sprintf("http://%s:%d/health", address, port),
			Interval: "10s",
			Timeout:  "3s",
		},
	}
	return r.client.Agent().ServiceRegister(reg)
}

func (r *ConsulRegistrar) Deregister() error {
	return r.client.Agent().ServiceDeregister(r.id)
}

func main() {
	consulAddr := "127.0.0.1:8500"
	serviceName := "user-service"
	serviceID := "user-service-1"
	host := "127.0.0.1"
	port := 8080

	registrar, err := NewConsulRegistrar(consulAddr)
	if err != nil {
		log.Fatalf("new consul registrar: %v", err)
	}

	if err := registrar.Register(serviceName, serviceID, host, port); err != nil {
		log.Fatalf("register service: %v", err)
	}
	defer registrar.Deregister()
	log.Println("service registered to consul")

	// 启动 HTTP 服务
	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})
	mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"id":1,"name":"alice"}`))
	})

	srv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: mux}
	go func() {
		log.Printf("http server on :%d", port)
		if err := srv.ListenAndServe(); err != nil && err.Error() != "http: Server closed" {
			log.Fatalf("listen: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	log.Println("shutting down")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_ = srv.Shutdown(ctx)
}

5. 客户端发现

调用方从 Consul 拉取服务实例列表,并在本地做负载均衡:

go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math/rand"
	"net/http"
	"time"

	"github.com/hashicorp/consul/api"
)

// ServiceDiscovery 基于 Consul 的服务发现
type ServiceDiscovery struct {
	client *api.Client
}

func NewServiceDiscovery(consulAddr string) (*ServiceDiscovery, error) {
	cfg := api.DefaultConfig()
	cfg.Address = consulAddr
	c, err := api.NewClient(cfg)
	if err != nil {
		return nil, err
	}
	return &ServiceDiscovery{client: c}, nil
}

// HealthyInstances 返回某个服务的健康实例
func (sd *ServiceDiscovery) HealthyInstances(serviceName string) ([]*api.ServiceEntry, error) {
	services, _, err := sd.client.Health().Service(serviceName, "", true, nil)
	if err != nil {
		return nil, err
	}
	return services, nil
}

// Pick 随机选一个实例(最简单的负载均衡)
func (sd *ServiceDiscovery) Pick(serviceName string) (*api.ServiceEntry, error) {
	instances, err := sd.HealthyInstances(serviceName)
	if err != nil {
		return nil, err
	}
	if len(instances) == 0 {
		return nil, fmt.Errorf("no instance for %s", serviceName)
	}
	r := rand.New(rand.NewSource(time.Now().UnixNano()))
	return instances[r.Intn(len(instances))], nil
}

func main() {
	sd, err := NewServiceDiscovery("127.0.0.1:8500")
	if err != nil {
		log.Fatal(err)
	}

	// 模拟客户端调用:每次随机选一个实例
	for i := 0; i < 5; i++ {
		inst, err := sd.Pick("user-service")
		if err != nil {
			log.Printf("pick failed: %v", err)
			continue
		}
		addr := fmt.Sprintf("http://%s:%d/users/1", inst.Service.Address, inst.Service.Port)
		log.Printf("calling instance: %s:%d", inst.Service.Address, inst.Service.Port)

		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		req, _ := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil)
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			log.Printf("call failed: %v", err)
			cancel()
			continue
		}
		body, _ := io.ReadAll(resp.Body)
		_ = resp.Body.Close()
		cancel()

		var u map[string]interface{}
		_ = json.Unmarshal(body, &u)
		fmt.Printf("response: %+v\n", u)
	}
}

6. 健康检查机制

Consul 的健康检查有几种类型:

  • HTTP 检查:定期 GET 一个 URL,2xx 视为健康。
  • TCP 检查:尝试建立 TCP 连接,成功即视为健康。
  • Script 检查:执行脚本,根据退出码判断(生产环境慎用,有安全风险)。
  • TTL 检查:服务主动上报「我还活着」,超时未上报视为不健康。
  • Docker 检查:通过 Docker daemon 检查容器状态。

TTL 检查示例(适合不想暴露健康端口的服务):

go
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/hashicorp/consul/api"
)

func startTTLHeartbeat(client *api.Client, serviceID, checkID string, interval time.Duration) {
	ticker := time.NewTicker(interval)
	go func() {
		for range ticker.C {
			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
			err := client.Agent().UpdateTTL(checkID, "alive", "pass")
			cancel()
			if err != nil {
				log.Printf("ttl update failed: %v", err)
			}
			_ = ctx
		}
	}()
}

func main() {
	cfg := api.DefaultConfig()
	cfg.Address = "127.0.0.1:8500"
	client, err := api.NewClient(cfg)
	if err != nil {
		log.Fatal(err)
	}

	serviceID := "user-service-2"
	checkID := "user-service-2-ttl"

	reg := &api.AgentServiceRegistration{
		ID:   serviceID,
		Name: "user-service",
		Port: 8081,
		Check: &api.AgentServiceCheck{
			TTL: "30s",
		},
	}
	if err := client.Agent().ServiceRegister(reg); err != nil {
		log.Fatal(err)
	}
	defer client.Agent().ServiceDeregister(serviceID)

	startTTLHeartbeat(client, serviceID, checkID, 10*time.Second)
	fmt.Println("service with TTL check started")

	time.Sleep(2 * time.Minute)
}

三、Etcd 实战

1. Etcd 简介

Etcd 是 CoreOS 开发的分布式 KV 存储,使用 Raft 协议保证强一致性。它是 Kubernetes 的核心组件之一,也常被用作服务注册中心。

Etcd 的特点是:

  • 简单:gRPC 接口,KV 模型清晰。
  • 安全:支持 TLS、RBAC。
  • 快速: Benchmarks 显示在多节点集群上每秒可处理上万次写入。
  • 可靠:Raft 协议保证强一致性。

2. 启动 Etcd

Docker 启动单节点:

bash
docker run -d --name etcd \
  -p 2379:2379 -p 2380:2380 \
  gcr.io/etcd-development/etcd:v3.5 \
  /usr/local/bin/etcd \
  --name s1 \
  --listen-client-urls http://0.0.0.0:2379 \
  --advertise-client-urls http://0.0.0.0:2379 \
  --listen-peer-urls http://0.0.0.0:2380 \
  --initial-advertise-peer-urls http://0.0.0.0:2380 \
  --initial-cluster s1=http://0.0.0.0:2380 \
  --initial-cluster-token tkn \
  --initial-cluster-state new

3. 基础 KV 操作

使用 go.etcd.io/etcd/client/v3

go
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	clientv3 "go.etcd.io/etcd/client/v3"
)

func main() {
	cli, err := clientv3.New(clientv3.Config{
		Endpoints:   []string{"127.0.0.1:2379"},
		DialTimeout: 5 * time.Second,
	})
	if err != nil {
		log.Fatal(err)
	}
	defer cli.Close()

	ctx := context.Background()

	// Put
	_, err = cli.Put(ctx, "/foo", "bar")
	if err != nil {
		log.Fatal(err)
	}

	// Get
	resp, err := cli.Get(ctx, "/foo")
	if err != nil {
		log.Fatal(err)
	}
	for _, ev := range resp.Kvs {
		fmt.Printf("key=%s value=%s\n", ev.Key, ev.Value)
	}

	// Get with prefix
	_, _ = cli.Put(ctx, "/users/1", "alice")
	_, _ = cli.Put(ctx, "/users/2", "bob")
	r2, _ := cli.Get(ctx, "/users/", clientv3.WithPrefix())
	for _, ev := range r2.Kvs {
		fmt.Printf("prefix key=%s value=%s\n", ev.Key, ev.Value)
	}
}

4. 基于租约(Lease)的服务注册

Etcd 的 Lease 机制非常适合服务注册:服务注册时绑定一个 Lease,并定期发送 KeepAlive 续约。一旦服务进程崩溃停止续约,Lease 自动过期,对应的 key 会被删除,调用方就能感知到。

go
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	clientv3 "go.etcd.io/etcd/client/v3"
)

// EtcdRegistrar 基于 Etcd Lease 的服务注册
type EtcdRegistrar struct {
	cli     *clientv3.Client
	leaseID clientv3.LeaseID
	key     string
}

func NewEtcdRegistrar(endpoints []string) (*EtcdRegistrar, error) {
	cli, err := clientv3.New(clientv3.Config{
		Endpoints:   endpoints,
		DialTimeout: 5 * time.Second,
	})
	if err != nil {
		return nil, err
	}
	return &EtcdRegistrar{cli: cli}, nil
}

func (r *EtcdRegistrar) Register(serviceName, instanceAddr string, ttl int64) error {
	ctx := context.Background()
	// 申请租约
	lease, err := r.cli.Grant(ctx, ttl)
	if err != nil {
		return err
	}
	r.leaseID = lease.ID
	r.key = fmt.Sprintf("/services/%s/%s", serviceName, instanceAddr)

	// 写入 key 并绑定租约
	_, err = r.cli.Put(ctx, r.key, instanceAddr, clientv3.WithLease(lease.ID))
	if err != nil {
		return err
	}

	// 自动续约
	ch, err := r.cli.KeepAlive(context.Background(), lease.ID)
	if err != nil {
		return err
	}
	go func() {
		for range ch {
			// 续约响应,可记录日志
		}
	}()
	return nil
}

func (r *EtcdRegistrar) Deregister() error {
	_, err := r.cli.Delete(context.Background(), r.key)
	return err
}

func main() {
	r, err := NewEtcdRegistrar([]string{"127.0.0.1:2379"})
	if err != nil {
		log.Fatal(err)
	}

	if err := r.Register("user-service", "127.0.0.1:8080", 10); err != nil {
		log.Fatal(err)
	}
	defer r.Deregister()
	fmt.Println("registered to etcd")

	time.Sleep(5 * time.Minute)
}

5. 基于 Watch 的服务发现

调用方通过 Watch 监听某个服务前缀下所有 key 的变化,本地维护一份实时实例列表:

go
package main

import (
	"context"
	"fmt"
	"log"
	"sync"
	"time"

	clientv3 "go.etcd.io/etcd/client/v3"
)

// EtcdDiscovery 基于 Etcd Watch 的服务发现
type EtcdDiscovery struct {
	cli       *clientv3.Client
	prefix    string
	mu        sync.RWMutex
	instances []string
}

func NewEtcdDiscovery(endpoints []string, prefix string) (*EtcdDiscovery, error) {
	cli, err := clientv3.New(clientv3.Config{
		Endpoints:   endpoints,
		DialTimeout: 5 * time.Second,
	})
	if err != nil {
		return nil, err
	}
	d := &EtcdDiscovery{cli: cli, prefix: prefix}
	if err := d.loadInitial(); err != nil {
		return nil, err
	}
	go d.watch()
	return d, nil
}

func (d *EtcdDiscovery) loadInitial() error {
	resp, err := d.cli.Get(context.Background(), d.prefix, clientv3.WithPrefix())
	if err != nil {
		return err
	}
	d.mu.Lock()
	for _, ev := range resp.Kvs {
		d.instances = append(d.instances, string(ev.Value))
	}
	d.mu.Unlock()
	return nil
}

func (d *EtcdDiscovery) watch() {
	rch := d.cli.Watch(context.Background(), d.prefix, clientv3.WithPrefix())
	for wresp := range rch {
		for _, ev := range wresp.Events {
			d.mu.Lock()
			switch ev.Type {
			case 0: // PUT
				d.instances = append(d.instances, string(ev.Kv.Value))
			case 1: // DELETE
				val := string(ev.PrevKv.Value)
				for i, v := range d.instances {
					if v == val {
						d.instances = append(d.instances[:i], d.instances[i+1:]...)
						break
					}
				}
			}
			d.mu.Unlock()
		}
	}
}

func (d *EtcdDiscovery) List() []string {
	d.mu.RLock()
	defer d.mu.RUnlock()
	out := make([]string, len(d.instances))
	copy(out, d.instances)
	return out
}

func main() {
	d, err := NewEtcdDiscovery([]string{"127.0.0.1:2379"}, "/services/user-service/")
	if err != nil {
		log.Fatal(err)
	}
	defer d.cli.Close()

	for i := 0; i < 10; i++ {
		fmt.Printf("instances: %v\n", d.List())
		time.Sleep(3 * time.Second)
	}
}

四、客户端负载均衡

1. 常见负载均衡策略

  • 随机(Random):从列表中随机选一个,简单但可能不均。
  • 轮询(Round Robin):按顺序循环选择,请求量均衡。
  • 加权轮询(Weighted RR):根据实例配置权重分配。
  • 最少连接(Least Connections):选择当前活跃请求数最少的实例。
  • 一致性哈希(Consistent Hash):相同请求特征路由到同一实例,便于缓存。

2. 实现一个加权轮询负载均衡器

go
package main

import (
	"fmt"
	"sync"
)

// Instance 服务实例
type Instance struct {
	Addr   string
	Weight int
}

// WeightedRoundRobin 加权轮询负载均衡器(平滑轮询算法)
type WeightedRoundRobin struct {
	mu        sync.Mutex
	instances []*Instance
	weights   []int // 当前权重
}

func NewWeightedRoundRobin(instances []*Instance) *WeightedRoundRobin {
	w := make([]int, len(instances))
	return &WeightedRoundRobin{instances: instances, weights: w}
}

func (lb *WeightedRoundRobin) Next() *Instance {
	lb.mu.Lock()
	defer lb.mu.Unlock()

	if len(lb.instances) == 0 {
		return nil
	}

	total := 0
	var best int
	for i, ins := range lb.instances {
		lb.weights[i] += ins.Weight
		total += ins.Weight
		if i == 0 || lb.weights[i] > lb.weights[best] {
			best = i
		}
	}
	lb.weights[best] -= total
	return lb.instances[best]
}

func main() {
	instances := []*Instance{
		{Addr: "10.0.0.1:8080", Weight: 5},
		{Addr: "10.0.0.2:8080", Weight: 1},
		{Addr: "10.0.0.3:8080", Weight: 1},
	}
	lb := NewWeightedRoundRobin(instances)

	counts := map[string]int{}
	for i := 0; i < 70; i++ {
		ins := lb.Next()
		counts[ins.Addr]++
	}
	for addr, c := range counts {
		fmt.Printf("%s -> %d\n", addr, c)
	}
}

五、go-kit 的服务发现集成

go-kit 是一个 Go 微服务工具集,它的 sd(service discovery)子包提供了对 Consul、Etcd、Zookeeper 等多个注册中心的统一封装。

下面是一个使用 go-kit + Consul 的简化示例骨架:

go
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	consulsd "github.com/go-kit/kit/sd/consul"
	"github.com/hashicorp/consul/api"
)

func main() {
	consulConfig := api.DefaultConfig()
	consulConfig.Address = "127.0.0.1:8500"
	consulClient, err := api.NewClient(consulConfig)
	if err != nil {
		log.Fatal(err)
	}
	client := consulsd.NewClient(consulClient)

	// 服务注册
	registrar := consulsd.NewRegistrar(client, &consulsd.ServiceRegistration{
		Name:    "user-service",
		ID:      "user-1",
		Address: "127.0.0.1",
		Port:    8080,
		Tags:    []string{"go", "user"},
	}, log.New(log.Writer(), "registrar: ", log.LstdFlags))

	registrar.Register()
	defer registrar.Deregister()

	// 服务发现(订阅者)
	// 真实项目里需要传入 instancer + endpointer,这里仅演示骨架
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	fmt.Println("service registered via go-kit")
	<-ctx.Done()
}

go-kit 的优势在于:把服务发现、负载均衡、重试、熔断等横切关注点抽象为统一的接口,切换注册中心只需替换 SD 实现,业务代码无需改动。

六、完整示例:服务端注册 + 客户端发现

最后我们用一个完整可运行的例子把本篇内容串起来。这个例子包含两个独立的 main 程序:

服务端(保存为 server/main.go):

go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/hashicorp/consul/api"
)

func main() {
	port := 8080
	if len(os.Args) > 1 {
		fmt.Sscanf(os.Args[1], "%d", &port)
	}
	serviceID := fmt.Sprintf("user-service-%d", port)

	// 注册到 Consul
	cfg := api.DefaultConfig()
	cfg.Address = "127.0.0.1:8500"
	consul, err := api.NewClient(cfg)
	if err != nil {
		log.Fatal(err)
	}
	reg := &api.AgentServiceRegistration{
		ID:      serviceID,
		Name:    "user-service",
		Address: "127.0.0.1",
		Port:    port,
		Check: &api.AgentServiceCheck{
			HTTP:     fmt.Sprintf("http://127.0.0.1:%d/health", port),
			Interval: "5s",
			Timeout:  "2s",
		},
	}
	if err := consul.Agent().ServiceRegister(reg); err != nil {
		log.Fatal(err)
	}
	defer consul.Agent().ServiceDeregister(serviceID)
	log.Printf("registered as %s on :%d", serviceID, port)

	// 启动 HTTP
	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	mux.HandleFunc("/users/1", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(fmt.Sprintf(`{"id":1,"name":"alice","port":%d}`, port)))
	})

	srv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: mux}
	go func() {
		if err := srv.ListenAndServe(); err != nil && err.Error() != "http: Server closed" {
			log.Fatalf("listen: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_ = srv.Shutdown(ctx)
	log.Println("server exited")
}

客户端(保存为 client/main.go):

go
package main

import (
	"context"
	"fmt"
	"log"
	"math/rand"
	"net/http"
	"time"

	"github.com/hashicorp/consul/api"
)

func main() {
	cfg := api.DefaultConfig()
	cfg.Address = "127.0.0.1:8500"
	consul, err := api.NewClient(cfg)
	if err != nil {
		log.Fatal(err)
	}

	for i := 0; i < 10; i++ {
		instances, _, err := consul.Health().Service("user-service", "", true, nil)
		if err != nil {
			log.Printf("discover failed: %v", err)
			continue
		}
		if len(instances) == 0 {
			log.Println("no healthy instance")
			continue
		}

		// 随机选一个实例
		ins := instances[rand.Intn(len(instances))]
		addr := fmt.Sprintf("http://%s:%d/users/1",
			ins.Service.Address, ins.Service.Port)

		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		req, _ := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil)
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			log.Printf("call failed: %v", err)
			cancel()
			continue
		}
		cancel()
		log.Printf("call %s -> %d", addr, resp.StatusCode)
		_ = resp.Body.Close()

		time.Sleep(1 * time.Second)
	}
	fmt.Println("done")
}

启动方式:

bash
# 终端1:启动 Consul
consul agent -dev -client=0.0.0.0

# 终端2、3、4:启动 3 个服务实例
go run server/main.go 8080
go run server/main.go 8081
go run server/main.go 8082

# 终端5:运行客户端
go run client/main.go

你会看到客户端的请求被分散到三个实例上,证明服务发现和负载均衡都在工作。

七、小结

本篇我们学习了微服务架构中最基础也最重要的能力——服务注册与发现:

  • 核心思路:通过一个注册中心集中维护服务实例列表,调用方动态查询并负载均衡。
  • 两种模式:客户端发现(调用方自己查询)和服务端发现(路由层查询转发)。
  • Consul:提供 HTTP API、Web UI、多类型健康检查、KV 存储,是开箱即用的方案。Go SDK 通过 github.com/hashicorp/consul/api 使用。
  • Etcd:基于 Raft 的强一致性 KV,配合 Lease + Watch 实现服务注册与发现,是 Kubernetes 的核心组件。
  • 健康检查:HTTP / TCP / TTL 多种方式,保证下线实例能被及时剔除。
  • 客户端负载均衡:随机、轮询、加权轮询、最少连接、一致性哈希等策略。
  • go-kit sd:抽象了多注册中心的差异,便于切换。

下一篇我们将进入 gRPC,学习如何用 Protobuf 定义服务契约、生成 Go 代码,并实现四种调用模式(一元、服务端流、客户端流、双向流)。gRPC 的高性能和强类型契约,是构建大规模 Go 微服务通信的首选。

延伸阅读