当健康检查发现一个上游账号宕机时,接下来该发生什么?路由系统要立刻把它从调度池剔除,告警系统要通知值班人,审计日志要记下这次故障------如果这些动作都由健康检查器自己 逐个去调用,它会变得臃肿、耦合,而且任何一个被调用方卡壳都会拖住它。HeySmart 的答案是:健康检查器只做一件事------把"账号状态变了"这件事喊出来。至于谁听、听到后做什么,是其他人的事。这个"喊话与听话"的机制,就是事件总线。
一、为什么需要事件总线?
先看一个没有事件总线的世界。
假设健康检查器发现账号 A 连续失败 5 次,状态转为 error。它需要:
- 更新账号 A 的状态------→ 直接调路由系统的方法;
- 通知告警------→ 直接调告警服务的方法;
- 记录审计------→ 直接调审计日志的方法。
三个调用,三个依赖关系。健康检查器从此认识路由系统、告警系统、审计系统。如果告警系统挂了,健康检查器也跟着遭殃;如果下周要再加一个"用量同步"的监听者,又得改动健康检查器的代码。
markdown
❌ 同步调用(紧耦合)
健康检查器 ──► 路由系统(更新状态)
│
├──► 告警服务(发告警)
│
└──► 审计日志(记日志)
任何一个被调用方变慢/宕机 → 健康检查器被阻塞
新增一个监听者 → 必须改健康检查器代码
事件总线把"调用"反转过来:
markdown
✅ 发布-订阅(松耦合)
健康检查器 ──► 事件总线 ──► 路由系统(更新状态)
│
├──► 告警服务(发告警)
│
└──► 审计日志(记日志)
健康检查器只发布一个事件,立即返回,不等待任何人
新增监听者 → 只需订阅,健康检查器完全无感
一个发布者,多个订阅者,异步解耦 ------这就是事件总线的全部思想。发布者不关心"谁在听",订阅者不关心"谁在说",两者之间只有一条约定:事件的主题(Topic)。
二、核心设计:Worker 池 + 有界队列
配置即语义
事件总线的核心实现(internal/kernel/eventbus/eventbus.go,374 行)只有两个配置参数,却定义了全部行为:
go
// Config 总线配置
type Config struct {
Workers int // 消费 worker 数
QueueSize int // 待处理事件队列容量;满时按 At-Most-Once 语义丢弃并告警
}
| 参数 | 默认值 | 控制什么 |
|---|---|---|
Workers |
4 | 并发消费事件的 goroutine 数,决定事件处理的总吞吐 |
QueueSize |
1024 | 待处理事件的队列容量,决定发布者的"等待余量" |
内核在构造时注入(kernel.go:57-60 eventbus.New,取自配置 EventBus.WorkerPoolSize / EventBus.QueueSize)。
Publish:入队即返回
go
select {
case b.queue <- job{ctx: context.WithoutCancel(ctx), event: event, target: target, recordID: recordID}:
return nil
case <-b.done:
return ErrNotStarted
default:
// 队列满:At-Most-Once 丢弃,保证主链路不被反压
b.dropped.Add(1)
// ...告警日志
return nil
}
Publish 把事件投进队列后立即返回------发布者绝不会因为某个订阅者处理得慢而被阻塞。worker 池从队列中取事件,逐个回调订阅者。
两个值得注意的设计细节:
context.WithoutCancel(ctx):事件处理使用脱离请求取消链路的上下文(保留 values 如 RequestID)。一次请求结束 cancel 了它的 ctx,但基于该请求发布的事件(比如审计)仍会继续处理完------事件处理不受请求生死影响。- 无订阅者免入队 :
len(target)==0时直接返回 nil,不浪费队列空间。
队列满:宁可丢事件,不可拖垮主链路
这是整个总线最重要的取舍(eventbus.go:234 注释):队列满时事件被丢弃 (At-Most-Once,最多投递一次),并打印告警日志(记录累计丢弃计数 dropped_total)。
为什么敢丢?因为事件总线的定位是旁路 ------它的工作是"让主链路更清爽",而不是"替主链路做核心交易"。丢一次审计事件、丢一次插件刷新通知,带来的损失远小于"事件处理反压导致 API 请求排队变慢"。主链路优先,是总线设计的第一原则。
为什么不选 Kafka / RabbitMQ?
| 对比维度 | HeySmart 事件总线 | Kafka / RabbitMQ |
|---|---|---|
| 部署复杂度 | 零:进程内 goroutine + channel | 独立集群,运维成本高 |
| 延迟 | 微秒级(内存队列) | 毫秒级(网络 + 落盘) |
| 可靠性 | At-Most-Once + 可选持久化 | At-Least-Once(需幂等配合) |
| 吞吐需求 | 请求级旁路事件,量级有限 | 海量流式数据 |
| 事务一致性 | 与内核同进程,天然一致 | 跨系统,需分布式事务 |
事件总线解决的是进程内组件间异步解耦 :健康检查器、Admin 层、内核------它们住在同一个进程里,需要的是一种"免费、低延迟、够用就好"的通信管道。为这个量级引入消息中间件,等于为送一封信开一家邮局。架构的优雅在于选对复杂度,而不是堆上复杂度。
三、订阅与优先级:谁先听,谁后听
两种订阅方式
go
// 按主题订阅:只收到该主题的事件
b.Subscribe(topic string, priority int, handler EventHandler) (Subscription, error)
// 通配订阅:接收所有事件(不限主题)
b.SubscribeWildcard(priority int, handler EventHandler) (Subscription, error)
- 按主题订阅 :
plugin.enable事件只会到达订阅了plugin.enable的回调;内核在启动时订阅了插件控制事件(kernel.go:120-128),Admin 层在管理后台发布它们------两端以事件主题为约定,互不认识实现。 - 通配订阅:不挑主题,任何事件都接收------适合监控、全量审计这类"什么都想看"的监听者。
优先级:数字越小越先听
同一事件有多个订阅者时,按 priority 升序 执行(稳定排序,同优先级保持订阅顺序,eventbus.go:210)。形同钩子引擎的优先级规则------在一个设计语言下保持一致的语义,是这套架构辨识度极高的地方。
幂等安全的退订
go
func (s *busSubscription) Unsubscribe() {
if s.done.CompareAndSwap(false, true) {
s.bus.removeByID(s.subID, s.topic, s.wildcard)
}
}
每个订阅持有全局唯一的 subID,按 ID 精确退订;CompareAndSwap 保证重复调用 Unsubscribe 安全 ------不会误删后来注册的订阅。退订实现还用了三索引切片(deleteEntry,eventbus.go:182-189)避免底层数组泄漏。
四、事件持久化与故障恢复:重启不丢事
纯内存总线有一个软肋:进程重启,队列里未处理的事件全部蒸发。如果一次"账号故障"事件恰好在上报途中服务重启,故障的感知可能就永远丢了。
三段式生命周期
事件总线通过可选的持久化层(store.go,EventStore 接口)把事件记录成三种状态:
scss
Publish ──► Save(pending) ──► Worker 消费 ──► MarkDelivered(全部成功)
│ └──► MarkFailed(任一订阅者失败,retry_count+1)
│
└── 进程重启 ──► RecoverPending 重新投递
| 状态 | 含义 | 何时写入 |
|---|---|---|
pending |
已落库、待处理 | Publish 时(如果配置了 store) |
delivered |
已全部投递成功 | worker 消费完成 |
failed |
有订阅者处理失败 | 任一订阅者返回错误/panic,retry_count 累加 |
落库:先存后投
Publish 在入队之前 先把事件写入 event_records 表(store.go:75 Save,topic + JSON payload,状态 pending)。落库失败只告警、不阻断投递------尽量不丢,但绝不牺牲主链路。
重启恢复
内核启动时调用 RecoverPending(ctx, 100)(kernel.go:131):扫描所有 pending 状态的事件(按创建时间升序),反序列化后重新投递(eventbus.go:327)。所以:
服务重启期间积压的未处理事件,会在重启后自动重新分发------健康检查故障的上报、插件状态的变更通知,一个都不会因为重启而永久丢失。
一个细节:恢复时的重新投递会再次调用 Save 落一条新记录(幂等的,只是历史表多一行),这正是"At-Most-Once 语义 + 重启补偿"的务实组合------平时尽量不丢,真丢了也有恢复通道。
五、Publish vs PublishSync:异步给数据,同步给控制
一句话区分:数据平面用异步,控制平面用同步。
go
// Publish:异步。入队即返回,发布者不阻塞。
// 数据平面事件专用:审计、状态上报、插件刷新通知。
func (b *Bus) Publish(ctx context.Context, event interfaces.Event) error
// PublishSync:同步。在调用方 goroutine 内直接执行订阅者。
// 控制平面事件专用:内核生命周期。
func (b *Bus) PublishSync(ctx context.Context, event interfaces.Event)
为什么内核生命周期事件必须同步?因为语义不同:
| 事件 | 通道 | 为什么 |
|---|---|---|
plugin.enable/disable/reload(Admin 热更新) |
Publish 异步 |
管理后台的操作应该立即返回;插件状态在后台慢慢切换 |
rate-limit 规则热更 |
PublishSync 同步 |
规则变更必须确保投递成功 才返回,否则管理员以为改了、实际没生效(rate_limit_rules.go:223 注释明确"同步投递不丢事件") |
kernel.started / kernel.stopping |
PublishSync 同步 |
启动/停止的收尾顺序必须严格,异步会引入竞态------kernel.stopping 必须赶在插件 Stop 之前送达(kernel.go:143) |
看 kernel.Stop 的顺序(kernel.go:139-147):
go
k.events.PublishSync(ctx, interfaces.NewEvent("kernel.stopping", nil)) // 1. 先广播"要停了"
k.plugins.StopAll(ctx) // 2. 再逆序停插件
_ = k.events.Stop() // 3. 最后关总线
如果第 1 步用异步 Publish,事件还在队列里排队时插件已经停了------订阅者永远收不到"要停了"的通知。同步通道保证:在下一次状态改变之前,前一个状态的事实一定已经送达。 这是"保序"的极简实现:用同步换取顺序,用异步换取性能,各取所需。
六、HeySmart 中的事件应用场景
全局扫描全部事件主题,可以看到这套总线已经落地的工作,以及为未来预留的插槽:
已落地的真实场景
| 事件主题 | 发布方 | 订阅方 | 通道 | 用途 |
|---|---|---|---|---|
kernel.started |
内核启动(kernel.go:113) |
各插件 | 同步 | 广播"内核就绪"(携带驱动数量),触发插件启动后的初始化 |
kernel.stopping |
内核停止(kernel.go:143) |
各插件 | 同步 | 广播"即将停机",保证停插件前事实必达 |
plugin.enable |
Admin 层(plugin_manager.go:136) |
内核(kernel.go:120 → handlePluginEnable) |
异步 | 管理后台启用插件,内核把插件状态机切到 running |
plugin.disable |
Admin 层(plugin_manager.go:181) |
内核(kernel.go:123 → handlePluginDisable) |
异步 | 管理后台禁用插件 |
plugin.reload |
Admin 层(plugin_manager.go:224/378)、限流规则服务(rate_limit_rules.go:223) |
内核(kernel.go:126 → handlePluginReload) |
异步 + 同步 | 插件配置热更新;限流规则变更走同步确保不丢 |
plugin.loaded / plugin.removed / plugin.reloaded |
WASM 沙箱(wasm/hotreload.go:318/110/339) |
待扩展 | 异步 | WASM 插件装载/卸载/重载的状态广播 |
一次真实的热更新旅程
以管理员在后台修改限流规则为例,看看事件总线如何把一次配置变更送达执行端:
css
管理员修改限流规则(Admin 后台)
│
▼
RateLimitRuleService(rate_limit_rules.go:223)
│ PublishSync("plugin.reload", {name: "rate-limit"})
▼
事件总线(同步投递,不丢事件)
│
▼
内核 handlePluginReload(kernel.go:493)
│ 从插件管理器取出 rate-limit 插件实例
│ 检查状态:started 可直接重载;failed 且实现了 Reloader 才可恢复
▼
插件 HotReload → 原子替换限流器指针
│
▼
下一次请求到来,限流插件 Hook 读到的已是新规则
全程管理员的操作立即返回(规则先落库)、内核在后台完成热切换、在途请求用的旧限流器不受影响(读写锁 + 原子指针交换)。事件总线是这条链路的"送信员",它不关心信的内容,只保证送达。
未来扩展的插槽
规划文档标注了几个"预留场景",当前代码尚未实现(本文如实区分):
account.health_changed:健康检查发现账号状态变化 → 路由系统更新调度池 + 告警 + 审计。当前健康检查与调度状态联动走直接调用(ReportFailure/ReportSuccess接口),未来可平滑迁移到事件驱动,让"状态变化"通知与"告警/审计"解耦;billing.session.captured:计费实扣完成 → 触发用量统计聚合。当前计费直连审计,未来可用事件总线广播,让报表系统独立订阅。
这些插槽恰恰证明了总线设计的价值:能力按需生长,核心机制不动。
结语:连接所有模块的"神经系统"
如果把钩子引擎比作血管 (请求流动的通道),事件总线就是神经系统------它不运送主流量,但负责把"发生了什么"传遍全身。二者相辅相成:
| 钩子引擎 | 事件总线 | |
|---|---|---|
| 通道方向 | 请求内:一次请求的生命周期 | 请求外:跨组件、跨时刻 |
| 同步性 | 同步链式(必须按序完成) | 异步为主(发布即返回) |
| 关系 | 插件挂进请求链路 | 组件之间松耦合通信 |
| 一致性 | 强一致(请求成败依赖它) | At-Most-Once(旁路尽力) |
这套双通道设计让 HeySmart 在保持主链路快、稳、可预期 的同时,让所有"伴随动作"(热更新、状态广播、恢复补偿)异步、解耦、可扩展。一个组件想参与系统的协作,只需要订阅一个主题------它不必认识任何人,也无需被任何人认识。
下一步:事件总线让组件之间"喊话",但插件本身如何被管理、被热更新、被隔离?请关注文章 04《插件架构与热更新》------插件状态机、Reloader 接口与快照模式,如何让"无需重启"成为现实。
HeySmart --- 让每一次 AI 调用都稳定可靠。 smart.funkits.cn