一、灰度配置更新
原理:每个实例归属某个组,按组更新配置,实现部分服务器更新配置。
流程:
1)服务启动向 etcd 注册自己的实例名, IP 等信息;服务后台 routine 定时的 30 秒去刷新租约;
2)watach监控实例ID敌营私有的配置,里边有所属的分组;
3)watch分组配置,发现改变则执行那个策略。
下面给一份可直接落地的示例,分两块:
① 实例注册(租约 + 定时刷新 IP) 和
② 分组监听 + 策略热更,合成一个网关进程里的两个 goroutine。路径沿用我们前面定好的结构。
etcd 路径约定
/im/gateway/register/{instanceId} # 网关自己写,IP/端口/版本,带 30s 租约
/im/gateway/instance_group/{instanceId} # 后台写,值 stable / canary(私有分组配置)
/im/gateway/config/{group}/strategy # 分组策略(QPM、字节上限、去重 TTL/条数)
rxupdateconfig.go
go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"sync"
"sync/atomic"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
)
// sync.WaitGroup 没有 WaitTimeout,用 goroutine + channel 包:
// 注意:超时后旧 goroutine 仍可能在跑(泄漏),但至少进程能退出------退出路径宁可泄漏也不能卡死。
func waitWithTimeout(wg *sync.WaitGroup, d time.Duration) bool {
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}
// ============ 配置模型 ============
// 路径约定:
// /im/gateway/register/{instanceId} # 网关自己写,IP/端口/版本,带 30s 租约
// /im/gateway/instance_group/{instanceId} # 后台写,值 stable / canary(私有分组配置)
// /im/gateway/config/{group}/strategy # 分组策略(QPM、字节上限、去重 TTL/条数)
// 模拟管理台设置:
// etcdctl put /im/gateway/instance_group/gateway-01 'canary'
// etcdctl put /im/gateway/config/canary/strategy '{"group":"canary","qpm":60,"byte_per_min":5242880,"dedup_ttl":100,"dedup_max":200}'
// GroupStrategy 分组限流/去重策略,对应前面讨论的阈值
type GroupStrategy struct {
Group string `json:"group"`
QPM int64 `json:"qpm"` // 每分钟消息条数
BytePerMin int64 `json:"byte_per_min"` // 每分钟字节上限
DedupTTL int64 `json:"dedup_ttl"` // 去重 TTL(秒),比如私聊存默认120秒,150条
DedupMax int `json:"dedup_max"` // 单账号去重最大条数
}
// 当前生效策略:请求路径只读这个原子指针,热更时整体替换
var strategy atomic.Pointer[GroupStrategy]
const (
leaseTTL = 30 // 注册租约 30s
refreshPeriod = 15 // 每 15s 续租一次(小于 TTL,防 GC/抖动导致过期)
defaultInstance = "stable"
)
// 生成默认的
func defaultStrategy(group string) *GroupStrategy {
return &GroupStrategy{
Group: group,
QPM: 60,
BytePerMin: 5 * 1024 * 1024,
DedupTTL: 120,
DedupMax: 200,
}
}
/*
# linux
# 设置临时(当前shell生效,重启终端失效)
export INSTANCE_ID="gateway-01"
# 查看
echo $INSTANCE_ID
# 持久化(比如写入 ~/.bashrc /etc/profile,所有用户)
echo 'export INSTANCE_ID="gateway-01"' >> ~/.bashrc
source ~/.bashrc
*/
/*
# windows
# 临时(当前PS窗口)
$env:INSTANCE_ID="gateway-01"
# 查看
$env:INSTANCE_ID
# 用户级永久
[Environment]::SetEnvironmentVariable("INSTANCE_ID", "gateway-01", "User")
# 系统级永久(管理员PS)
[Environment]::SetEnvironmentVariable("INSTANCE_ID", "gateway-01", "Machine")
*/
func instanceID() string {
v := os.Getenv("INSTANCE_ID")
if v == "" {
log.Fatal("未配置 INSTANCE_ID")
}
return v
}
// 探测本机对外 IPv4(跳过回环);多网卡场景这里可指定业务网卡
//func localIP() string {
// addrs, err := net.InterfaceAddrs()
// if err != nil {
// return "127.0.0.1"
// }
// for _, a := range addrs {
// if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
// return ipnet.IP.String()
// }
// }
// return "127.0.0.1"
//}
// 优先读环境变量,运维显式指定
func localIP() string {
if v := os.Getenv("BIND_IP"); v != "" {
return v
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return "127.0.0.1"
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil {
continue
}
ip := ipnet.IP
// 跳过 link-local 169.254.0.0/16
if ip4 := ip.To4(); ip4 != nil && ip4[0] == 169 && ip4[1] == 254 {
continue
}
// 跳过链路本地 IPv6 也可顺手加
return ip.String()
}
return "127.0.0.1"
}
// ============ 程序一:实例注册 + 定时刷新 ============
func registerLoop(ctx context.Context, cli *clientv3.Client, id, ip, port string, wg *sync.WaitGroup) {
defer wg.Done() // 协程退出时通知
leaseID := clientv3.LeaseID(0)
for {
if err := registerOnce(ctx, cli, id, ip, port, &leaseID); err != nil {
if ctx.Err() != nil {
log.Printf("[注册] 收到退出信号,停止注册循环(最后一次错误: %v)", err)
return
}
log.Printf("[注册] 失败: %v,%ds 后重试", err, refreshPeriod)
}
select {
case <-ctx.Done():
log.Printf("[注册] 收到退出信号,停止注册循环")
return // 关键:及时退出,否则 wg.Wait() 卡住
case <-time.After(refreshPeriod * time.Second):
}
}
}
// 这个应该是写到程序发布版本里的吧
func getVersion() string {
v := os.Getenv("VERSION")
if v == "" {
v = "version 1.0 default"
}
return v
}
func registerOnce(ctx context.Context, cli *clientv3.Client, id, ip, port string, leaseID *clientv3.LeaseID) error {
if *leaseID != 0 {
_, err := cli.Lease.KeepAliveOnce(ctx, *leaseID)
if err == nil {
// 续租成功,顺带刷新 value(把当前策略写进去)
if err := putRegisterInfo(ctx, cli, id, ip, port, *leaseID); err != nil {
log.Printf("[注册] 续租成功但刷新 value 失败: %v", err)
}
return nil
} else if ctx.Err() == nil {
log.Printf("续租失败了,网关id=%s, err=%v", id, err)
}
*leaseID = 0
}
resp, err := cli.Grant(ctx, leaseTTL)
if err != nil {
return err
}
*leaseID = resp.ID
return putRegisterInfo(ctx, cli, id, ip, port, *leaseID)
}
func putRegisterInfo(ctx context.Context, cli *clientv3.Client, id, ip, port string, leaseID clientv3.LeaseID) error {
info := map[string]any{
"instance_id": id,
"ip": ip,
"port": port,
"version": getVersion(),
"ts": time.Now().Format(time.RFC3339),
}
if st := strategy.Load(); st != nil {
info["group"] = st.Group
info["strategy"] = map[string]any{
"group": st.Group,
"qpm": st.QPM,
"byte_per_min": st.BytePerMin,
"dedup_ttl": st.DedupTTL,
"dedup_max": st.DedupMax,
}
}
b, err := json.Marshal(info)
if err != nil {
return fmt.Errorf("marshal register info: %w", err)
}
_, err = cli.Put(ctx,
fmt.Sprintf("/im/gateway/register/%s", id),
string(b),
clientv3.WithLease(leaseID),
)
if err != nil {
log.Printf("设置网关id=%s, 错误%v", id, err)
}
return err
}
// ============ 程序二:监听私有分组配置 + 热更分组策略 ============
type groupWatcher struct {
cli *clientv3.Client
id string
cfgCancel context.CancelFunc // 旧分组配置 watch 的取消句柄,切分组时必须关掉
innerWG sync.WaitGroup // 旧的取消后,等待结束
}
// 这个是一个带重试的版本,这里是挖watch的入口函数
func StartGroupWatcher(ctx context.Context, wg *sync.WaitGroup, cli *clientv3.Client, id string) {
defer wg.Done()
backoff := 5 * time.Second
const maxBackoff = 60 * time.Second
const stableThreshold = 30 * time.Second // run 存活超过 30s 视为"稳定"
for {
if ctx.Err() != nil {
// ctrl + c
//fmt.Println(errors.Is(ctx.Err(), context.Canceled))
return
}
gw := &groupWatcher{cli: cli, id: id}
start := time.Now()
gw.run(ctx) // 阻塞运行,直到 ctx 取消或不可恢复错误
if ctx.Err() != nil {
return // 是退出信号导致的结束,不再重建
}
// 关键:稳定运行过一段时间,说明之前的错误已恢复,backoff 复位
if time.Since(start) >= stableThreshold {
backoff = 5 * time.Second
}
log.Printf("[分组] 监听中断,%v 后重建 (backoff)", backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
// 这个函数可以阻塞方式运行,
func (w *groupWatcher) run(ctx context.Context) {
defer func() {
if w.cfgCancel != nil {
w.cfgCancel() // 退出时关掉子 watch,防止 goroutine 泄漏
}
if !waitWithTimeout(&w.innerWG, 5*time.Second) {
log.Printf("[分组] 等待旧 watch 退出超时,强制继续")
}
log.Printf("[分组] 收到退出信号,停止分组监听")
}()
// 加载组名,这里是跟踪默认的分组是否有变更
currentGroup := w.loadGroupName(ctx)
w.startConfigWatchGroup(ctx, currentGroup)
// 这里是跟踪网关实例所属的分组是否有变化
key := fmt.Sprintf("/im/gateway/instance_group/%s", w.id)
log.Printf("开始跟踪网关=%s, 所属配置分组key=%s, 当前分组=%s", w.id, key, currentGroup)
/*
下面的循环是通过ctx来关闭watch的:
Ctrl+C
→ signal.NotifyContext 内部 cancel
→ ctx.Done() 关闭
→ clientv3.Watcher 内部 select 到 ctx.Done()
→ 关闭该 watch 的底层 stream,close(rch)
→ for resp := range rch 结束
*/
rch := w.cli.Watch(ctx, key)
for resp := range rch {
if resp.Err() != nil {
if ctx.Err() != nil {
//fmt.Println(errors.Is(ctx.Err(), context.Canceled))
return // 程序退出,正常结束 context.Canceled
}
if resp.CompactRevision != 0 {
log.Printf("[分组] instance_group watch revision 已 compact (compactRev=%d), err=%v,重建", resp.CompactRevision, resp.Err())
} else {
log.Printf("[分组] watch 不可恢复错误: %v", resp.Err())
}
return
}
for _, ev := range resp.Events {
g := string(ev.Kv.Value)
if g == "" {
g = defaultInstance
}
if g == currentGroup {
continue
}
log.Printf("[分组] 变更: %s -> %s", currentGroup, g)
currentGroup = g
// 指定分组名字换了,需要切换分组
w.switchGroup(ctx, g)
}
}
}
// 读取私有分组配置;etcd 缺失或异常时兜底 stable
//
// etcdctl put /im/gateway/instance_group/gateway-01 'canary'
func (w *groupWatcher) loadGroupName(ctx context.Context) string {
key := fmt.Sprintf("/im/gateway/instance_group/%s", w.id)
resp, err := w.cli.Get(ctx, key)
if err != nil || len(resp.Kvs) == 0 {
log.Printf("查找网关策略分组:id=%s, 遇到错误 %v, 这里使用默认的策略 %s \n", w.id, err, defaultInstance)
return defaultInstance
}
if g := string(resp.Kvs[0].Value); g != "" {
return g
}
return defaultInstance
}
// 控制台对实例的分组名字切换后,实例会切换到新的分组的配置上
func (w *groupWatcher) switchGroup(ctx context.Context, group string) {
if w.cfgCancel != nil {
w.cfgCancel() // 关键:取消旧分组 watch,防止句柄泄漏、配置串台
}
if !waitWithTimeout(&w.innerWG, 5*time.Second) {
log.Printf("[分组] 等待旧 watch 退出超时,强制继续")
}
w.startConfigWatchGroup(ctx, group)
}
// 启动指定分组的策略监听,并先全量加载一次
// key的名字类似 /im/gateway/config/canary/strategy
func (w *groupWatcher) startConfigWatchGroup(ctx context.Context, group string) {
log.Printf("startConfigWatchGroup() 准备开始监控新的策略配置:%s", group)
defer func() {
log.Printf("startConfigWatchGroup() 函数执行完毕,启动了协程监控:%s", group)
}()
w.loadStrategy(ctx, group)
prefix := fmt.Sprintf("/im/gateway/config/%s/", group)
var opts []clientv3.OpOption
opts = append(opts, clientv3.WithPrefix())
resp, err := w.cli.Get(ctx, prefix, clientv3.WithPrefix())
if err == nil {
opts = append(opts, clientv3.WithRev(resp.Header.Revision+1))
if len(resp.Kvs) == 0 {
log.Printf("[策略] 分组 %s 当前无配置,watch 已就绪,等待管理台下发生效", group)
}
} else {
log.Printf("读取配置%s失败,降级为从当前时刻开始 watch(仅可能漏此瞬间的变更)", prefix)
}
cctx, cancel := context.WithCancel(ctx)
w.cfgCancel = cancel
// 从当前版本之后开始 watch,不漏事件
rch := w.cli.Watch(cctx, prefix, opts...)
// 等待这个协程结束
w.innerWG.Add(1)
go func() {
defer w.innerWG.Done()
for r := range rch {
if r.Err() != nil {
return
}
w.loadStrategy(cctx, group) // 配置变更:整组重新拉取,原子替换指针
}
}()
}
// 拉取分组策略;拉不到就用兜底默认值,不因 etcd 抖动导致限流失效
func (w *groupWatcher) loadStrategy(ctx context.Context, group string) bool {
if ctx.Err() != nil {
return false
}
log.Printf("loadStrategy() 开始加载全量配置:%s", group)
defer func() {
log.Printf("loadStrategy() 函数退出 %s", group)
}()
key := fmt.Sprintf("/im/gateway/config/%s/strategy", group)
resp, err := w.cli.Get(ctx, key)
cur := strategy.Load()
if err != nil || len(resp.Kvs) == 0 {
if cur == nil {
log.Printf("[策略] 分组 %s 配置缺失,使用兜底默认值", group)
strategy.Store(defaultStrategy("default"))
} else {
log.Printf("[策略] 分组 %s 配置缺失,沿用当前策略(当前 group=%s)", group, cur.Group)
}
return false
}
var st GroupStrategy
if err := json.Unmarshal(resp.Kvs[0].Value, &st); err != nil {
if cur == nil {
log.Printf("[策略] 分组 %s JSON配置错误%v,使用兜底默认值", group, err)
strategy.Store(defaultStrategy("default"))
} else {
log.Printf("[策略] 解析失败: %v,保留当前策略%s", err, cur.Group)
}
return false
}
strategy.Store(&st)
return true
}
main.go
go
package main
import (
"context"
clientv3 "go.etcd.io/etcd/client/v3"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
)
// TIP <p>To run your code, right-click the code and select <b>Run</b>.</p> <p>Alternatively, click
// the <icon src="AllIcons.Actions.Execute"/> icon in the gutter and select the <b>Run</b> menu item from here.</p>
func etcdEndpoints() []string {
v := os.Getenv("ETCD_ENDPOINTS")
if v == "" {
log.Fatal("未配置 ETCD_ENDPOINTS,例如 export ETCD_ENDPOINTS=127.0.0.1:2379 $env:ETCD_ENDPOINTS=\"127.0.0.1:2379\"")
}
// 支持逗号分隔多节点
var eps []string
for _, s := range strings.Split(v, ",") {
if s = strings.TrimSpace(s); s != "" {
eps = append(eps, s)
}
}
if len(eps) == 0 {
log.Fatal("ETCD_ENDPOINTS 解析后为空")
}
return eps
}
func main() {
// 监听 SIGINT SIGTERM,收到信号自动cancel
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
id := instanceID()
cli, err := clientv3.New(clientv3.Config{
Endpoints: etcdEndpoints(),
DialTimeout: 5 * time.Second,
})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 尝试连接一次看看
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if _, err := cli.Get(probeCtx, "/"); err != nil {
log.Fatalf("etcd 连接失败: %v", err)
}
log.Println("etcd 连接正常")
log.Printf("etcd client created, instance id: %s", id)
ip := os.Getenv("BIND_IP")
if ip == "" {
ip = localIP()
}
log.Printf("注册使用 IP: %s", ip)
port := "9000"
///////////////////////////////////////////////////////////////
wg := sync.WaitGroup{}
wg.Add(1)
go registerLoop(ctx, cli, id, ip, port, &wg) // 程序一:注册 IP + 定时刷新
wg.Add(1)
// 方案一:简单启动一个协程
//gw := &groupWatcher{cli: cli, id: id}
//go gw.run(ctx, &wg) // 程序二:监听分组 + 热更策略
// 方案二:这里的监控可能出现各种异常的错误,需要重启
go StartGroupWatcher(ctx, &wg, cli, id)
//- 通道**没关闭的时候**:`<-ctx.Done()` 阻塞在这里,主 goroutine 卡住,不往下跑
//- 当操作系统发来 SIGINT / SIGTERM → signal.NotifyContext 内部 cancel ctx → Done 通道关闭
//- 通道关闭之后,`<-ctx.Done()` 立刻解除阻塞,代码继续往下走,进入优雅收尾逻辑
// 演示:消息路径里直接读当前生效策略
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
mainLoop:
for {
select {
case <-ctx.Done():
break mainLoop
case <-ticker.C:
log.Printf("[运行] 当前策略: %+v", strategy.Load())
}
}
// ========== 优雅关闭区 ==========
// 走到这里代表收到退出信号
// 1. 停止接收新连接
// 2. 等待现有websocket消息处理完成
// 3. registerLoop、gw.run 已经收到ctx.Done,正在退出
log.Println("main 收到退出信号,等待后台协程退出...")
waitWithTimeout(&wg, 5*time.Second)
log.Println("main graceful shutdown done")
}
运行方式
bash
// 获取库
go get go.etcd.io/etcd/client/v3
//设置etcd地址
// linux
export ETCD_ENDPOINTS=127.0.0.1:2379
export INSTANCE_ID="gateway-01"
// windows
$env:ETCD_ENDPOINTS="127.0.0.1:2379"
$env:INSTANCE_ID="gateway-01"
配套写入 etcd 的示例数据
bash
# 分组策略示例
etcdctl put /im/gateway/config/stable/strategy \
'{"group":"stable","qpm":60,"byte_per_min":5242880,"dedup_ttl":120,"dedup_max":200}'
etcdctl put /im/gateway/config/canary/strategy \
'{"group":"canary","qpm":80,"byte_per_min":10485760,"dedup_ttl":120,"dedup_max":300}'
# 后台(内控系统)写私有分组:把 im-gateway-01 设为 canary
etcdctl put /im/gateway/instance_group/im-gateway-01 canary
# 执行设置分组,就会执行配置切换
# 后台(内控系统)写私有分组:把 im-gateway-01 设为 canary
etcdctl put /im/gateway/instance_group/im-gateway-01 stable
备注:图形界面有个Etcd Workbench很好用,https://tzfun.github.io/etcd-workbench/
go build
.\rxupdate.exe
2026/10/22 15:12:00 etcd 连接正常
2026/10/22 15:12:00 etcd client created, instance id: gateway-01
2026/10/22 15:12:00 注册使用 IP: 192.168.1.2
2026/10/22 15:12:00 startConfigWatchGroup() 准备开始监控新的策略配置:canary
2026/10/22 15:12:00 loadStrategy() 开始加载全量配置:canary
2026/10/22 15:12:00 loadStrategy() 函数退出 canary
2026/10/22 15:12:00 startConfigWatchGroup() 函数执行完毕,启动了协程监控:canary
2026/10/22 15:12:00 开始跟踪网关=gateway-01, 所属配置分组key=/im/gateway/instance_group/gateway-01, 当前分组=canary
2026/10/22 15:12:05 [运行] 当前策略: &{Group:canary QPM:60 BytePerMin:5242880 DedupTTL:100 DedupMax:200}
2026/10/22 15:13:04 [分组] 变更: canary -> stable
2026/10/22 15:13:04 startConfigWatchGroup() 准备开始监控新的策略配置:stable
2026/10/22 15:13:04 loadStrategy() 开始加载全量配置:stable
2026/10/22 15:13:04 loadStrategy() 函数退出 stable
2026/10/22 15:13:04 startConfigWatchGroup() 函数执行完毕,启动了协程监控:stable
2026/10/22 15:13:05 [运行] 当前策略: &{Group:stable QPM:100 BytePerMin:5242880 DedupTTL:200 DedupMax:400}
2026/10/22 15:13:10 [运行] 当前策略: &{Group:stable QPM:100 BytePerMin:5242880 DedupTTL:200 DedupMax:400}
2026/10/22 15:13:15 [运行] 当前策略: &{Group:stable QPM:100 BytePerMin:5242880 DedupTTL:200 DedupMax:400}
2026/10/22 15:13:20 [运行] 当前策略: &{Group:stable QPM:100 BytePerMin:5242880 DedupTTL:200 DedupMax:400}
2026/10/22 15:21:08 [注册] 收到退出信号,停止注册循环
2026/10/22 15:21:08 main 收到退出信号,等待后台协程退出...
2026/10/22 15:21:08 [分组] 收到退出信号,停止分组监听
2026/10/22 15:21:08 main graceful shutdown done