跳至主要内容

Go编写etcd注册中心Demo

可以。用 etcd 做注册中心,核心其实就三件事:

  1. 服务启动 → 注册实例
  2. 服务存活 → 续租(KeepAlive)
  3. 服务下线/租约过期 → etcd 自动删除实例,消费者通过 Watch 感知

下面给你一个尽量接近真实微服务场景、但保持最小化的 Go Demo。

1. 安装依赖

go get go.etcd.io/etcd/client/v3

假设本地 etcd:

127.0.0.1:2379

2. 服务注册

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()

    // 创建 10 秒租约
    leaseResp, err := cli.Grant(context.Background(), 10)
    if err != nil {
        log.Fatal(err)
    }

    // 注册服务
    key := "/services/order/192.168.1.10:8080"

    _, err = cli.Put(
        context.Background(),
        key,
        `{"host":"192.168.1.10","port":8080}`,
        clientv3.WithLease(leaseResp.ID),
    )
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("服务注册成功:", key)

    // 自动续租
    keepAliveCh, err := cli.KeepAlive(
        context.Background(),
        leaseResp.ID,
    )
    if err != nil {
        log.Fatal(err)
    }

    // 持续消费续租响应
    for resp := range keepAliveCh {
        fmt.Println("续租成功,TTL:", resp.TTL)
    }
}

这里最关键的是:

clientv3.WithLease(leaseResp.ID)

意味着:

/service/order/192.168.1.10:8080
              │
              └── 绑定 lease
                       │
                       └── TTL = 10s

只要服务不断:

KeepAlive
   ↓
Lease TTL重新变成10s
   ↓
KeepAlive
   ↓
Lease TTL重新变成10s

如果服务直接崩溃,无法继续 KeepAlive:

10s
 ↓
9s
 ↓
...
 ↓
0
 ↓
Lease 过期
 ↓
etcd 自动删除 key

所以不需要服务自己执行注销操作


3. 消费者发现服务

例如 order 服务可能有三个实例:

/services/order/10.0.0.1:8080
/services/order/10.0.0.2:8080
/services/order/10.0.0.3:8080

消费者可以:

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()

    prefix := "/services/order/"

    // 先获取当前所有实例
    resp, err := cli.Get(
        context.Background(),
        prefix,
        clientv3.WithPrefix(),
    )
    if err != nil {
        log.Fatal(err)
    }

    for _, kv := range resp.Kvs {
        fmt.Printf(
            "发现服务: %s -> %s\n",
            string(kv.Key),
            string(kv.Value),
        )
    }

    // 监听后续变化
    watchCh := cli.Watch(
        context.Background(),
        prefix,
        clientv3.WithPrefix(),
    )

    for watchResp := range watchCh {
        for _, event := range watchResp.Events {

            switch event.Type {

            case clientv3.EventTypePut:
                fmt.Println(
                    "服务上线/更新:",
                    string(event.Kv.Key),
                    string(event.Kv.Value),
                )

            case clientv3.EventTypeDelete:
                fmt.Println(
                    "服务下线:",
                    string(event.Kv.Key),
                )
            }
        }
    }
}

于是整个过程就是:

                 etcd
                  │
       ┌──────────┼──────────┐
       │          │          │
   order-1    order-2    order-3
       │          │          │
     lease      lease      lease
       │          │          │
    KeepAlive  KeepAlive  KeepAlive


consumer
    │
    ├── Get /services/order/
    │
    └── Watch /services/order/
              │
              ├── PUT    → 新实例上线
              │
              └── DELETE → 实例下线

4. 真实项目一般会再抽象一层

你可以把它封装成:

type Registry struct {
    client *clientv3.Client
}

func (r *Registry) Register(
    serviceName string,
    instanceID string,
    addr string,
) error

func (r *Registry) Unregister(
    serviceName string,
    instanceID string,
) error

func (r *Registry) Discover(
    serviceName string,
) ([]string, error)

func (r *Registry) Watch(
    serviceName string,
    callback func(event Event),
)

这样业务代码基本就是:

registry.Register(
    "order",
    "192.168.1.10:8080",
    "192.168.1.10:8080",
)

消费者:

instances, _ := registry.Discover("order")

registry.Watch("order", func(event Event) {
    // 更新本地服务列表
})

这里有一个非常重要的设计点:消费者通常不会每次请求都去 etcd 查询,而是:

             etcd
               │
          Watch事件流
               ↓
       ┌────────────────┐
       │ Consumer本地缓存 │
       │                 │
       │ 10.0.0.1:8080   │
       │ 10.0.0.2:8080   │
       │ 10.0.0.3:8080   │
       └───────┬────────┘
               │
          请求 order
               ↓
        本地负载均衡

这才是注册中心真正有价值的地方:etcd负责服务状态的一致性,本地缓存负责请求路径上的高性能服务发现。

此博客中的热门博文

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...

事务的ACID是什么

 事务的 ACID 是数据库事务必须满足的四个基本性质,用来保证在并发和故障情况下数据的正确性与可靠性: A(Atomicity,原子性) 一个事务中的操作要么 全部成功 ,要么 全部失败回滚 ,不存在“只做了一半”的中间状态。 C(Consistency,一致性) 事务执行前后,数据库都必须处于 一致的合法状态 ,满足约束(如主键、外键、唯一性、业务规则等)。 I(Isolation,隔离性) 并发执行的多个事务之间 相互隔离 ,一个事务未提交的中间结果对其他事务不可见(具体强弱由隔离级别决定)。 D(Durability,持久性) 一旦事务提交成功,其结果会被 永久保存 ,即使系统崩溃也不会丢失(通常依赖 WAL/redo log 等机制)。 一句话记忆: 要么全做完、前后不破坏规则、互不干扰、做完不丢。