1. 3.4 热更新与运行时调参
线上常出现「参数只在真实负载下才看清」的情况:worker 池容量、RPC 待处理上限、令牌桶速率等。若每次都 改文件→构建→滚动重启,反馈周期长且打断连接与队列。
zhenyi 在 Actor 单线程模型下,通过 safeUpdate → CmdTypeSafeFn → 邮箱排队,把参数变更闭包固定排入 处理线程,避免与 Run 并发改同一批字段(见 zhenyi/zactor/handlemsg.go、hotupdate.go)。
1.1. 3.4.1 为什么需要热更新?
生产环境中,很多参数在开发阶段很难确定最优值:
| 参数 | 难点 |
|---|---|
| 协程池大小 | 取决于实际并发量,线上才能测准 |
| 限流阈值 | 流量忽高忽低,需要动态调整 |
| RPC 并发槽数 | 取决于下游服务的响应速度 |
这些参数不应该"一次性定死"。热更新让你可以在服务运行期间观察指标、调整参数、验证效果,不需要每次都重启。
1.2. 3.4.2 问题:怎么在 Actor 单线程模型下安全地改参数?
Actor 的消息是串行处理的,只有一个 goroutine 在跑。如果外部 goroutine 直接修改 Actor 的内部状态,就会破坏单线程不变性。
// ❌ 错误:外部直接修改,可能和 Actor 主循环并发冲突
actor.workerPool.Tune(newSize)
actor.Rate = newRate
解决方案:通过 Mailbox 投递修改请求。
1.3. 3.4.3 SafeFn:安全的运行时修改
zhenyi 提供了 safeUpdate 方法,把修改逻辑封装成消息投递到 Actor 的 Mailbox:
func (a *Actor) safeUpdate(fn func()) {
cmd := zmodel.ActorCmd{
Type: zmodel.CmdTypeSafeFn,
Fn: fn,
}
a.Push(cmd) // 投递到 Mailbox
}
实际分支在 SafeHandleMessage 内与各类 ActorCmd 一起分发;SafeFn 最终在 mailbox 线程执行闭包(等价于与业务消息同一顺序域)。具体函数名以 handlemsg.go 为准。
1.3.1. 使用方式
// 运行时调整协程池大小
actor.UpdateWorkerPoolSize(50)
// 运行时调整限流参数
actor.UpdateRateLimit(rate, burst)
// 运行时重建协程池(极端情况,比如检测到池死锁)
actor.RebuildWorkerPool(100)
每个方法内部都是 safeUpdate:
func (a *Actor) UpdateWorkerPoolSize(newSize int) {
a.safeUpdate(func() {
if a.workerPool == nil || newSize <= 0 {
return
}
a.workerPool.Tune(newSize)
a.GetLogger().Info("Worker pool size updated",
zap.Int("newSize", newSize))
})
}
1.4. 3.4.4 具体能调什么?
1.4.1. 协程池大小
actor.UpdateWorkerPoolSize(50)
// 底层调用 ants.PoolWithFunc.Tune()
// 调整协程池容量,正在运行的任务不受影响
适用场景:
- 线上监控发现协程池利用率持续 > 80%,需要扩容
- 流量高峰过后,缩容释放资源
1.4.2. 限流参数
actor.UpdateRateLimit(1000, 2000)
// Rate: 每秒允许处理的请求数
// Burst: 突发容量
适用场景:
- 下游服务变慢,需要降低请求速度
- 下游恢复后,调高限流
1.4.3. RPC 并发槽(与 CallActor 相关)
actor.UpdateMaxRPCPending(4096)
内部同样走 safeUpdate,用于运行时调整 RPC Slot 数量(见 zhenyi/zactor/hotupdate.go)。
1.4.4. 重建协程池
actor.RebuildWorkerPool(100)
// 完全销毁旧池,创建新池
这是极端操作,适用于:
- 协程池死锁(任务卡住不退出)
Tune无法满足(需要改更多配置)
重建过程中正在运行的任务会继续执行完,新任务走新池。
1.5. 3.4.5 SafeFn 的设计考量
SafeFn 看起来简单,但有几个设计点值得说:
1.5.1. 为什么不用锁?
用读写锁也能保护参数修改,但锁的粒度不好控制——锁太粗影响性能,锁太细容易漏。通过 Mailbox 投递,修改操作和消息处理天然串行,不需要考虑锁的粒度。
1.5.2. 为什么 SafeFn 闭包可以访问 Actor 状态?
闭包是在调用方构造的,但在 Actor 主线程执行。这意味着:
- 构造闭包时:在调用方 goroutine(可能在监控线程、定时器线程等)
- 执行闭包时:在 Actor 主线程(串行,安全)
// 在监控 goroutine 中
actor.UpdateWorkerPoolSize(50)
// → 构造闭包 { a.workerPool.Tune(50) }
// → Push 到 Mailbox
// → Actor 主线程取出执行 { a.workerPool.Tune(50) } ← 这里是安全的
闭包捕获的 a(Actor 指针)在整个生命周期内有效,不会有悬空引用的问题。
1.5.3. SafeFn 和消息处理的顺序
SafeFn 和普通消息在同一个 Mailbox 中排队,先到先处理。这意味着:
- 如果你发了一条 SafeFn,后面跟了一条普通消息,SafeFn 先执行
- 不存在"SafeFn 插队"的问题,顺序是确定的
1.6. 3.4.6 优雅退出:排空邮箱
上一节提到了 Actor 的 Run 方法在收到关闭信号后会排空邮箱。这里补充完整的关闭流程:
ctx.Done()
↓
┌──────────────────────────────────────┐
│ Actor.Run │
│ │
│ shouldExit = true │
│ ┌─────────────────────────────┐ │
│ │ 继续处理 Mailbox 中的消息 │ │
│ │ msg1 → msg2 → msg3 → ... │ │
│ └─────────────────────────────┘ │
│ n == 0 → return │
│ │
│ Close:取消 ctx、取消远端订阅、 │
│ 关闭 mailBoxQueue、释放 workerPool │
└──────────────────────────────────────┘
关键点:
- 不丢消息:关闭信号只是设置标志,不中断处理
- 最终一致:Mailbox 中的消息会全部处理完才退出
- 资源释放:
Actor.Close会取消总线订阅、关闭邮箱队列、释放协程池等;连接侧 readBuffer / RingBuffer 属于 Channel 生命周期,不在此节展开
Group 层面还有 WaitForDrain,在超时内等待所有 superviseActor goroutine 结束(内部 g.wg.Wait()):
func (g *Group) WaitForDrain(timeout time.Duration) bool {
done := make(chan struct{})
go func() {
g.wg.Wait()
close(done)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-done:
return true
case <-timer.C:
return false
}
}
1.7. 3.4.7 本节要点
- SafeFn 路径:
safeUpdate封装为ActorCmd入队,与业务处理 FIFO 串行。 - 可调项示例:
UpdateWorkerPoolSize、UpdateRateLimit、UpdateMaxRPCPending、RebuildWorkerPool(见hotupdate.go)。 - 设计动机:用邮箱顺序代替对外部可见字段的随意写,减少与
Run的隐蔽竞态。 - 关闭:
ctx.Done()/closeCh后Run排空再退出;Group 侧可WaitForDrain等待监督协程结束。
3.5 节:进程内 RPC 熔断与连接层限流(与 3.3 的熔断叙述衔接)。