小Cの已经记不起来的博客

Go 语言 配置中心 实战:从原理到落地

起因

最近手上的项目从单实例拆成了四个实例,配置文件也跟着从一份变成了四份。上周想临时调一下日志级别排查线上问题,结果是改四个 yaml、重新构建、挨个重启,折腾完小半个小时没了,问题早就凉透了。改到第三次的时候实在忍不了,决定花个周末把这事儿彻底解决。

现成的方案不少,Nacos、Apollo、Consul,个个都挺能打。Apollo 功能最全,但部署得带上 MySQL 和一堆管理端服务;Nacos 也得自己养一套存储。对我们这种小项目来说属实有点杀鸡用牛刀(也可能是我瞎怕麻烦吧...)。最后选了 etcd,原因很简单:我们本来就靠它做服务注册,顺手把配置也塞进去,一个组件两件事全干了。

原理:配置中心到底在干嘛

说白了就三件事:

  1. 集中存储:配置别再散落在各台机器上,统一放一个地方,带版本,改错了能翻旧账。
  2. 变更推送:配置改了之后,正在跑的进程要能感知到,不用重启。
  3. 容错:配置中心挂了,进程得能靠手里最后一份配置继续活着。

推送一般两种路子:轮询和长连接。轮询就是进程每隔几秒问一次“配置变了没”,实现简单但延迟高、还浪费;etcd 走的是基于 gRPC stream 的 watch,服务端一有变动立刻推过来,基本没有延迟。Apollo 用的 HTTP 长轮询算是个折中,客户端挂起请求三十秒,有变更就提前返回。我们这种规模,watch 足够了。

动手:先把配置读起来

etcd 的 Go 客户端就一个,go.etcd.io/etcd/client/v3,直接拉下来,拉不动的先看看 GOPROXY 配了没:

go env -w GOPROXY=https://goproxy.cn,direct
go get go.etcd.io/etcd/client/v3

连接、读一份配置,代码大概长这样,DialTimeout 记得设上,不然连不上会干等着卡死:

cli, err := clientv3.New(clientv3.Config{
    Endpoints:   []string{"127.0.0.1:2379"},
    DialTimeout: 5 * time.Second,
})
if err != nil {
    log.Fatalf("connect etcd failed: %v", err)
}
defer cli.Close()

resp, err := cli.Get(context.Background(), "/myapp/config")
if err != nil {
    log.Fatalf("get config failed: %v", err)
}
if len(resp.Kvs) == 0 {
    log.Fatal("config not found")
}
fmt.Println(string(resp.Kvs[0].Value))

不出问题的话就没有问题了,/myapp/config 里我放的是一段 JSON:

{
  "log_level": "info",
  "mysql_dsn": "user:pass@tcp(127.0.0.1:3306)/myapp",
  "read_timeout": 3000
}

json.Unmarshal 解到结构体里就行,没什么好说的。

热更新:watch 才是灵魂

只读一次那跟本地文件没啥区别,配置中心的价值全在 watch 上。思路是起一个 goroutine 盯着 key,一有变动就重新解析,回调给业务方:

func WatchConfig(cli *clientv3.Client, key string, onUpdate func([]byte)) {
    for {
        rch := cli.Watch(context.Background(), key)
        for wresp := range rch {
            for _, ev := range wresp.Events {
                if ev.Type == clientv3.EventTypePut {
                    onUpdate(ev.Kv.Value)
                }
            }
        }
        // 能走到这儿说明 watch 断了,歇两秒重连
        time.Sleep(2 * time.Second)
    }
}

业务方注册回调的时候别干重活,改日志级别这种几行代码的事儿就很合适:

WatchConfig(cli, "/myapp/config", func(val []byte) {
    var cfg Config
    if err := json.Unmarshal(val, &cfg); err != nil {
        log.Printf("parse new config failed: %v", err)
        return
    }
    level, _ := logrus.ParseLevel(cfg.LogLevel)
    logrus.SetLevel(level)
    logrus.Infof("config updated, log level: %s", cfg.LogLevel)
})

改完 etcdctl put /myapp/config '...',进程日志立刻就打出来了,那一刻还是挺爽的。

踩过的几个坑

watch 会收到历史事件。服务重启后如果从旧 revision 开始听,之前所有人改过配置的事件会给你重放一遍。解决办法是先 Get 拿到当前 revision,再带着 clientv3.WithRev(resp.Header.Revision + 1) 去 watch,只听新的。

新配置可能是坏的。运维手一抖把 JSON 写残了,解析失败还好,就怕解析成功但值离谱,比如超时时间填了个 0,请求全超。所以回调里必须做校验,不合法就打日志报警、保留旧配置,千万别让脏配置生效。

etcd 挂了怎么办。watch 断了进程还在跑,用的还是内存里那份旧配置,问题不大;但进程要是重启就连不上配置中心了。所以每次成功拉到配置就往本地文件写一份缓存,启动的时候优先连 etcd,连不上就读本地文件,保证服务能起来。

小结

整套东西拢共不到两百行代码,没依赖什么大家伙,但集中管理、热更新、容错三件事都齐了。当然它没有权限控制、没有灰度发布、也没有回滚界面,这些等真需要的时候再上 Nacos 也不迟。工具嘛,够用就好,没准儿等项目长大了,今天这套代码就是最好的练手货。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放