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 自动回收 |
| 并发安全 | 是 | 是(原子操作) |
核心就一句话:把"只能写一次的变量"换成"随时可以原子替换的变量",读的人拿到的是替换那一刻的快照,不会被后续替换影响。