Go 服务 Graph 热更新实践:用 atomic.Value 替代 sync.Once

Go 服务 Graph 热更新实践:用 atomic.Value 替代 sync.Once

背景

我们的 AI 销售服务(Go)内部有多个 Graph(基于 eino 框架的 DAG 工作流),用于处理不同的业务场景:AI 跟进、聊天分析、线索意向分析等。

原来的实现用 sync.Once 在启动时编译一次 graph,之后永远不变。问题是:如果 skill 配置改了、prompt 更新了,唯一的办法是重启服务。重启期间正在处理的请求直接断掉。

目标:不重启服务的情况下更新 graph,且不影响正在处理的请求。

原来的代码为什么不行

go 复制代码
var (
    cachedGraph  compose.Runnable[Input, Output]
    graphOnce    sync.Once
)

func InitGraph(ctx context.Context) error {
    graphOnce.Do(func() {
        cachedGraph, _ = buildGraph(ctx)
    })
    return nil
}

func GetGraph() (compose.Runnable[Input, Output], error) {
    return cachedGraph, nil
}

sync.Once 的特性:只执行一次,永远不能再执行第二次。

  • 启动时编译了一次,存到 cachedGraph
  • 之后 graphOnce.Do 里的函数不会再跑
  • 想更新?只能重启
  • 重启期间,正在处理的请求直接中断

解决方案:atomic.Value

核心思路

atomic.Value 是 Go 标准库 sync/atomic 包提供的类型,支持两个操作:

  • Store(val) --- 存入一个值(原子操作,瞬间完成)
  • Load() --- 取出当前值(原子操作,瞬间完成)

用它替代全局变量后:

go 复制代码
var instance atomic.Value

// 请求进来时
graph := instance.Load()  // 拿到当前 graph 实例

// 热更新时
newGraph := buildGraph(ctx)  // 先编译好新的
instance.Store(newGraph)     // 原子替换,瞬间完成

为什么正在处理的请求不受影响?

请求 A 在热更新前调了 Load(),拿到的是旧 graph 的指针 。之后 Store() 换了新的,只是改了 atomic.Value 里存的地址。请求 A 手里的旧指针依然有效,它继续用旧 graph 跑完。旧 graph 没人用了之后,Go 的 GC 自动回收。

实现:统一 Registry

不在每个 graph 包里各自搞一个 atomic.Value,而是做一个统一的 registry 管理所有 graph。

registry.go 核心结构

go 复制代码
type Builder func(ctx context.Context) (any, error)

type entry struct {
    instance atomic.Value  // 存储编译后的 graph 实例
    builder  Builder       // 编译函数,reload 时调用
}

var (
    mu      sync.Mutex
    entries = make(map[string]*entry)
)

注册

各 graph 包在 init() 中注册自己的编译函数:

go 复制代码
func init() {
    graphutil.Register("clue_intention", func(ctx context.Context) (any, error) {
        return buildGraph(ctx)
    })
}

这一步只是登记------告诉 registry "我叫什么、怎么编译我"。

初始化

服务启动时统一编译所有已注册的 graph:

go 复制代码
func InitAll(ctx context.Context) error {
    for _, name := range names {
        instance, err := e.builder(ctx)  // 调用 buildGraph
        e.instance.Store(instance)       // 存进 atomic.Value
    }
}

获取

请求进来时从 atomic.Value 读取当前实例:

go 复制代码
func Get[T any](name string) (T, error) {
    v := e.instance.Load()  // 原子读取
    return v.(T), nil
}

热更新

go 复制代码
func Reload(ctx context.Context, name string) []ReloadResult {
    // 1. 防并发:同一时间只允许一次 reload
    if !reloading.CompareAndSwap(false, true) {
        return "reload already in progress"
    }
    defer reloading.Store(false)

    // 2. 确定目标:name 为空则全部,否则指定单个
    // 3. 逐个重编译
    for _, n := range targets {
        instance, err := e.builder(ctx)  // 重新编译
        if err != nil {
            continue  // 失败则跳过,旧版不受影响
        }
        e.instance.Store(instance)  // 成功则原子替换
    }
}

各 graph 包的改动

改动模式统一,以 clue_intention 为例:

删掉的

  • sync.Once 变量
  • cachedGraph 全局变量
  • InitGraph() 函数

新增的

go 复制代码
const GraphName = "clue_intention"

func init() {
    graphutil.Register(GraphName, func(ctx context.Context) (any, error) {
        return buildGraph(ctx)
    })
}

func GetGraph() (compose.Runnable[Input, Output], error) {
    return graphutil.Get[compose.Runnable[Input, Output]](GraphName)
}

GetGraph() 对外接口不变,controller 不需要改任何代码。

bootstrap 的改动

go 复制代码
// 之前:逐个调用
import (
    "ai_sales_server/agent/graph/ai_follow"
    analysistaskv2 "ai_sales_server/agent/graph/analysis_task_v2"
    // ...
)
aifollow.InitGraph(ctx)
analysistaskv2.InitGraph(ctx)
// ... 5 行

// 之后:统一初始化
import (
    graph "ai_sales_server/agent/graph"
    _ "ai_sales_server/agent/graph/ai_follow"        // 只触发 init() 注册
    _ "ai_sales_server/agent/graph/analysis_task_v2"
    // ...
)
graph.InitAll(ctx)  // 1 行搞定

_ import 的意思:导入包但不直接调用它的函数,目的是让 init() 执行完成注册。

HTTP 触发接口

go 复制代码
// POST /admin/graph/reload?name=ai_follow  (指定单个)
// POST /admin/graph/reload                 (全部)

响应示例:

json 复制代码
{
  "code": 0,
  "message": "ok",
  "data": {
    "results": [
      {"name": "ai_follow", "success": true, "duration_ms": 120},
      {"name": "chat_analysis", "success": true, "duration_ms": 85}
    ],
    "total": 5,
    "succeeded": 5,
    "failed": 0
  }
}

完整调用链

scss 复制代码
启动时:
  各包 init() → Register(name, builder)
  bootstrap → graph.InitAll() → builder() → Store(实例)

运行时:
  请求 → controller → GetGraph() → Load() → 拿到实例 → 执行 graph

热更新:
  curl /admin/graph/reload → Reload() → builder() → Store(新实例)
  旧请求继续用旧的,新请求用新的,GC 回收旧的

总结

对比项 sync.Once atomic.Value
能否更新 不能,只执行一次 随时 Store 覆盖
更新方式 重启服务 调接口热更新
请求影响 重启时中断 零中断
旧版释放 不适用 GC 自动回收
并发安全 是(原子操作)

核心就一句话:把"只能写一次的变量"换成"随时可以原子替换的变量",读的人拿到的是替换那一刻的快照,不会被后续替换影响。

相关推荐
AI人工智能+电脑小能手5 小时前
【大白话说Java面试题 第77题】【Mysql篇】第7题:回表查询与全表扫描的区别?
java·开发语言·数据库·mysql·面试
张元清5 小时前
在 React 里写动画又不跟渲染周期较劲:useRafFn、useRafState、useFps、useDevicePixelRatio、useUpdate
前端·javascript·面试
代码帮6 小时前
面试题 - GIL全局解释器锁 :为什么Python多线程不能利用多核?GIL对I/O密集和CPU密集任务的影响?如何绕过GIL(多进程、C扩展)
python·面试
Raink老师6 小时前
【AI面试临阵磨枪-65】设计一个支持 10w 并发的 AI 聊天服务(流式、高可用、成本优化)
人工智能·面试·职场和发展
HelloRainy8 小时前
用 C++ 实现 shared_ptr 与 weak_ptr,线程安全是怎么解决的
面试
Java编程爱好者8 小时前
Kubernetes Pod 故障排查指南:从状态识别到根因定位的完整实践
面试
敲个大西瓜9 小时前
面经(1)
面试
雮尘9 小时前
100+ React 面试题 —— 来自前面试官的直接整理(2026)
前端·react.js·面试
Mahir0810 小时前
Spring MVC 深度解密:从 DispatcherServlet 到请求处理全流程
java·后端·spring·面试·mvc