IM系统后端 之(八) 配置信息灰度切换

一、灰度配置更新

原理:每个实例归属某个组,按组更新配置,实现部分服务器更新配置。

流程:

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