第 11 篇:优雅升级探针——先问“能停吗“,再动手升级 升级一个正在处理业务数据的模块相当于给正在比赛的跑车换轮胎。直接停掉升级会丢数据但无限等待又永远升不了。本文设计一套三阶段升级探针机制——让模块在忙完这一段后安全交接实现升级过程业务零中断。一、开篇场景数据采集模块正在收设备数据data-collector模块连接着 200 台 CNC 机床每台每秒上报 10 条加工数据。这些数据经过模块的缓冲区批量写入本地时序数据库。此时云端下发了一个升级命令data-collector要从 v1.0 升级到 v2.0。如果直接停掉 v1.0缓冲区里还有 500 条未写入的数据直接丢失。更严重的是——v1.0 正在处理一个三阶段的写事务只完成了第一阶段。停掉后事务不完整数据库进入不一致状态。如果等它忙完CNC 机床 24 小时不停数据采集模块永远忙。等到什么时候才能升级需要一个聪明的方案升级前先问模块你什么时候方便。模块自己最清楚自己什么时候处于安全状态缓冲区为空、没有进行中的事务。当它不方便时升级流程就等着当它说方便了立即升级。二、概念铺垫升级探针的本质升级探针Upgrade Probe是一个 HTTP 回调接口。模块暴露一个探针端点当被调用时返回自己当前是否处于可以安全停止的状态GET /probe/upgrade-ready 响应 HTTP 200 → 可以停了我现在空闲 HTTP 503 → 不行我正在处理业务过会儿再问但直接用这个探针就够了吗不够。因为升级前还有一些准备工作要做比如新版本镜像要先拉下来但不能影响旧版本运行。所以整个升级流程拆成三个阶段阶段做什么旧版本是否受影响Preloading拉取新版本镜像/包放本地缓存否Preloaded持续探测旧模块直到它返回空闲否Upgrading真正执行停旧→删旧→建新→启新是这是唯一的业务中断窗口三个阶段中前两个不影响旧模块运行。只有第三个阶段——Upgrading——才会中断业务但这个阶段的耗时是可控的停删建启通常 5~30 秒。三、方案设计升级编排器 升级任务持久化3.1 升级编排器UpgradeOrchestrator当部署清单的 Diff 发现某个模块的版本变了ModuleEvent 中的 UpdateModules 列表会被推送过来。升级编排器并不立即执行升级——而是检查是否有升级探针配置——如果没有走快速升级路径直接停旧启新。没有探针的模块通常是无状态模块不需要优雅停止。有探针 → 创建升级任务 → 进入三阶段流程。升级任务持久化到 SQLite。如果节点在 Preloaded 阶段断电重启后任务从 SQLite 恢复继续探测——不用重头拉镜像。3.2 三阶段升级流程typeUpgradeOrchestratorstruct{db*UpgradeTaskRepo// 升级任务持久化nodeCore*ModuleManagerProxy imageMgr*ImageManager runningTasksmap[string]*UpgradeTask mu sync.Mutex}typeUpgradeTaskstruct{TaskIDstringModuleIDstringOldConfig*ModuleConfig// 当前版本配置NewConfig*ModuleConfig// 目标版本配置Phase UpgradePhase// 当前阶段CreateTime time.Time UpdateTime time.Time}typeUpgradePhasestringconst(PhasePreloading UpgradePhasepreloadingPhasePreloaded UpgradePhasepreloadedPhaseUpgrading UpgradePhaseupgradingPhaseCompleted UpgradePhasecompleted)func(o*UpgradeOrchestrator)StartUpgrade(oldCfg,newCfg*ModuleConfig)error{// 创建升级任务task:UpgradeTask{TaskID:uuid.New().String(),ModuleID:newCfg.ModuleID,OldConfig:oldCfg,NewConfig:newCfg,Phase:PhasePreloading,}o.db.SaveTask(task)o.mu.Lock()o.runningTasks[task.TaskID]task o.mu.Unlock()// 异步执行升级流程goo.executeTask(task)returnnil}func(o*UpgradeOrchestrator)executeTask(task*UpgradeTask){// 阶段一Preloading task.PhasePhasePreloading o.db.UpdateTask(task)// 拉取新版本镜像不影响旧模块运行err:o.imageMgr.PullImage(task.NewConfig.Image)iferr!nil{o.failTask(task,拉取镜像失败: err.Error())return}// 阶段二Preloaded task.PhasePhasePreloaded o.db.UpdateTask(task)// 探测旧模块直到它返回空闲iftask.NewConfig.HasUpgradeProbe(){maxWait:30*time.Minute// 最多等 30 分钟deadline:time.Now().Add(maxWait)fortime.Now().Before(deadline){ready,err:o.probeModule(task.ModuleID,task.NewConfig.UpgradeProbeURL)iferr!nil{// 探测失败网络/超时可能是模块出问题了// 不等了直接进入升级阶段break}ifready{break// 模块说空闲了可以升级}time.Sleep(5*time.Second)// 5 秒后再问}// 超过等待时间或探测失败 → 仍然进入升级// 不能无限等待30 分钟已经足够模块完成任何正在进行的事务}// 阶段三Upgrading task.PhasePhaseUpgrading o.db.UpdateTask(task)// 执行升级短暂的业务中断窗口o.nodeCore.StopModule(task.ModuleID)o.nodeCore.RemoveModule(task.ModuleID)o.nodeCore.CreateModule(task.NewConfig)o.nodeCore.StartModule(task.ModuleID)// 升级完成task.PhasePhaseCompleted o.db.UpdateTask(task)// 清理旧版本镜像o.imageMgr.CleanImage(task.OldConfig.Image)}3.3 探针探测func(o*UpgradeOrchestrator)probeModule(moduleID,probeURLstring)(bool,error){ctx,cancel:context.WithTimeout(context.Background(),10*time.Second)defercancel()req,_:http.NewRequestWithContext(ctx,GET,probeURL,nil)resp,err:http.DefaultClient.Do(req)iferr!nil{returnfalse,err}deferresp.Body.Close()// 200 空闲503 忙returnresp.StatusCode200,nil}3.4 断电恢复边缘节点可能在升级三个阶段的任意时刻断电。重启后NodeCore 恢复 SQLite 中的模块数据第 5 篇同时升级编排器从 SQLite 恢复未完成的升级任务func(o*UpgradeOrchestrator)Recover(){// 从数据库加载所有未完成的升级任务tasks:o.db.FindUncompletedTasks()for_,task:rangetasks{// 恢复升级进度o.runningTasks[task.TaskID]taskswitchtask.Phase{casePhasePreloading:// 还没拉取新镜像从头开始goo.executeTask(task)casePhasePreloaded:// 镜像已拉取直接进入探测阶段task.PhasePhasePreloadedgoo.executeTask(task)casePhaseUpgrading:// 升级过程中断电——这是最危险的情况// 策略回滚到旧版本createBody下次拉取清单时重新触发升级o.nodeCore.StopModule(task.ModuleID)o.nodeCore.RemoveModule(task.ModuleID)o.nodeCore.CreateModule(task.OldConfig)o.nodeCore.StartModule(task.ModuleID)task.PhasePhaseCompleted o.db.UpdateTask(task)}}}四、Go 核心骨架模块侧探针实现示例作为模块开发者你需要在你的模块里暴露一个升级探针端点。下面是一个示例// 业务模块侧——暴露升级探针typeUpgradeProbeHandlerstruct{collector*DataCollector}func(h*UpgradeProbeHandler)ServeHTTP(w http.ResponseWriter,r*http.Request){// 检查三个条件// 1. 数据缓冲区为空没有待处理的消息// 2. 没有正在进行的事务// 3. 没有正在执行的长耗时操作bufferEmpty:h.collector.BufferSize()0noActiveTransaction:!h.collector.HasActiveTransaction()noLongRunningOp:!h.collector.HasLongRunningOp()ifbufferEmptynoActiveTransactionnoLongRunningOp{w.WriteHeader(200)w.Write([]byte({ready: true}))}else{w.WriteHeader(503)w.Write([]byte({ready: false, reason: buffer not empty or transaction in progress}))}}// 在主程序中注册funcmain(){probe:UpgradeProbeHandler{collector:myCollector}http.Handle(/probe/upgrade-ready,probe)http.ListenAndServe(:9090,nil)}五、边界与反模式反模式一探针返回 503 时无限等待错误做法模块永远返回 503业务永不停升级流程一直 Preloaded。为什么错这不是探针的问题——是业务逻辑的问题。如果模块真的 24/7 忙不停那你需要设计热升级方案双实例灰度切换而不是依赖升级探针。正确做法设置最大等待时间如 30 分钟。超时后强制执行升级——最多丢这 30 分钟内的数据但优雅停机会让模块尽量 flush 缓冲区。反模式二升级探针里有副作用错误做法探针被调用时顺便更新了某个计数器、写了一条日志到业务数据库。为什么错探针可能在 Preloaded 阶段被调用几十次每 5 秒一次30 分钟就是 360 次。如果每次调用都有副作用会对业务系统造成不必要的干扰。正确做法探针必须是只读的、无状态的——它只是一个状态查询不改变任何东西。反模式三没有升级锁错误做法同一个模块同时有多个升级任务在进行。正确做法升级编排器在创建升级任务前检查该模块是否已有进行中的任务有则拒绝。六、小结优雅升级探针的设计哲学让被升级者决定时机——模块最清楚自己什么时候安全前两阶段不影响业务——Preloading 和 Preloaded 都在旧版本旁运行持久化防断电——升级任务写入 SQLite杀了进程也能恢复进度超时兜底——不能无限等待30 分钟上限后强制执行这一篇结束了卷三控制面中关于怎么安全改变模块状态的全部内容。下一篇我们讨论极端场景——节点断网 30 天了怎么办主节点挂了备节点怎么顶上去本文是《边缘平台架构沉思录Go 架构推演与工程决策》系列的第 11 篇。