升级一个正在处理业务数据的模块,相当于"给正在比赛的跑车换轮胎"。直接停掉升级会丢数据,但无限等待又永远升不了。本文设计一套三阶段升级探针机制------让模块在"忙完这一段"后安全交接,实现升级过程业务零中断。
一、开篇场景:数据采集模块正在收设备数据
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 三阶段升级流程
go
type UpgradeOrchestrator struct {
db *UpgradeTaskRepo // 升级任务持久化
nodeCore *ModuleManagerProxy
imageMgr *ImageManager
runningTasks map[string]*UpgradeTask
mu sync.Mutex
}
type UpgradeTask struct {
TaskID string
ModuleID string
OldConfig *ModuleConfig // 当前版本配置
NewConfig *ModuleConfig // 目标版本配置
Phase UpgradePhase // 当前阶段
CreateTime time.Time
UpdateTime time.Time
}
type UpgradePhase string
const (
PhasePreloading UpgradePhase = "preloading"
PhasePreloaded UpgradePhase = "preloaded"
PhaseUpgrading UpgradePhase = "upgrading"
PhaseCompleted UpgradePhase = "completed"
)
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()
// 异步执行升级流程
go o.executeTask(task)
return nil
}
func (o *UpgradeOrchestrator) executeTask(task *UpgradeTask) {
// === 阶段一:Preloading ===
task.Phase = PhasePreloading
o.db.UpdateTask(task)
// 拉取新版本镜像(不影响旧模块运行)
err := o.imageMgr.PullImage(task.NewConfig.Image)
if err != nil {
o.failTask(task, "拉取镜像失败: "+err.Error())
return
}
// === 阶段二:Preloaded ===
task.Phase = PhasePreloaded
o.db.UpdateTask(task)
// 探测旧模块,直到它返回"空闲"
if task.NewConfig.HasUpgradeProbe() {
maxWait := 30 * time.Minute // 最多等 30 分钟
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
ready, err := o.probeModule(task.ModuleID, task.NewConfig.UpgradeProbeURL)
if err != nil {
// 探测失败(网络/超时),可能是模块出问题了
// 不等了,直接进入升级阶段
break
}
if ready {
break // 模块说空闲了,可以升级
}
time.Sleep(5 * time.Second) // 5 秒后再问
}
// 超过等待时间或探测失败 → 仍然进入升级
// 不能无限等待,30 分钟已经足够模块完成任何正在进行的事务
}
// === 阶段三:Upgrading ===
task.Phase = PhaseUpgrading
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.Phase = PhaseCompleted
o.db.UpdateTask(task)
// 清理旧版本镜像
o.imageMgr.CleanImage(task.OldConfig.Image)
}
3.3 探针探测
go
func (o *UpgradeOrchestrator) probeModule(moduleID, probeURL string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", probeURL, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
// 200 = 空闲,503 = 忙
return resp.StatusCode == 200, nil
}
3.4 断电恢复
边缘节点可能在升级三个阶段的任意时刻断电。重启后,NodeCore 恢复 SQLite 中的模块数据(第 5 篇),同时升级编排器从 SQLite 恢复未完成的升级任务:
go
func (o *UpgradeOrchestrator) Recover() {
// 从数据库加载所有"未完成"的升级任务
tasks := o.db.FindUncompletedTasks()
for _, task := range tasks {
// 恢复升级进度
o.runningTasks[task.TaskID] = task
switch task.Phase {
case PhasePreloading:
// 还没拉取新镜像,从头开始
go o.executeTask(task)
case PhasePreloaded:
// 镜像已拉取,直接进入探测阶段
task.Phase = PhasePreloaded
go o.executeTask(task)
case PhaseUpgrading:
// 升级过程中断电------这是最危险的情况
// 策略:回滚到旧版本(createBody),下次拉取清单时重新触发升级
o.nodeCore.StopModule(task.ModuleID)
o.nodeCore.RemoveModule(task.ModuleID)
o.nodeCore.CreateModule(task.OldConfig)
o.nodeCore.StartModule(task.ModuleID)
task.Phase = PhaseCompleted
o.db.UpdateTask(task)
}
}
}
四、Go 核心骨架:模块侧探针实现示例
作为模块开发者,你需要在你的模块里暴露一个升级探针端点。下面是一个示例:
go
// 业务模块侧------暴露升级探针
type UpgradeProbeHandler struct {
collector *DataCollector
}
func (h *UpgradeProbeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 检查三个条件:
// 1. 数据缓冲区为空(没有待处理的消息)
// 2. 没有正在进行的事务
// 3. 没有正在执行的长耗时操作
bufferEmpty := h.collector.BufferSize() == 0
noActiveTransaction := !h.collector.HasActiveTransaction()
noLongRunningOp := !h.collector.HasLongRunningOp()
if bufferEmpty && noActiveTransaction && noLongRunningOp {
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"}`))
}
}
// 在主程序中注册
func main() {
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 篇。