运营管理系统里有一个很基础的问题:怎么知道一个运营现在是否在线?
最小实现很简单。前端每隔几秒发一个心跳包,服务端保存 last_seen_at。如果超过一段时间没有收到心跳,就把这个人标成离线。
这个版本能点亮一个在线灯。要支撑派单、排班、监控和工时统计,它还差很多上下文。运营是否在线,需要同时看他是否有任务、属于哪个技能组、当前状态能不能接单、是否在灰度规则里、异常状态是否已经通知过、下游系统是否已经拿到最新状态。
我维护的 HCM/Heartbeat 系统服务 47,149 个 agent、7,000 个 skill group、约 4,000 QPS 和 141 个上游调用方。这篇文章从一个最朴素的实现开始,一层一层把系统推出来。
先看最终目标:

图 0:最终目标是一套状态平台。图片由 gpt-image-2 生成。用户动作先经过统一工作台 SDK、WS-API 和 Frontier 长链,再进入 Heartbeat、MQ、Compute、HCM 和下游状态传播链路。
下面从 last_seen_at 开始,一步步把这张图搭出来。
每加一层,都先问一个问题。
问题 1:怎么知道运营有没有活跃?
第一版可以直接写状态。

图 1:最朴素的心跳实现。图片由 gpt-image-2 生成。它可以记录最近心跳时间,但缺少任务数、技能组、业务规则和 region 信息。
这个方案的数据结构大概是:
1 | agent_id |
前端每隔 N 秒发心跳。服务端更新 last_seen_at。定时任务扫描超时的人,把状态改成 OFFLINE。
这个模型适合很轻的场景,比如显示一个在线灯。先不讨论任务数、技能组和规则,单看 UI 到 Heartbeat 的接入层,就已经有一组工程问题:
- 一个工作台页面里有多个业务模块,不能每个模块都自己建一条通道。
- 用户动作是突发的,连续输入不能变成同步写入风暴。
- 浏览器长链会断,重连期间的消息要能补回来。
- 长链推送和短链补偿可能送到同一条逻辑消息。
- Heartbeat 服务重启后,输入流不能丢。
- 状态计算要能独立发布,不应该和浏览器 SDK 绑死。
last_seen_at 只能提供一个时间戳。下一步要解决的是:怎么把浏览器动作变成可靠的事件流。
所以第一版适合作为思维起点,生产系统还要继续往前推。
问题 2:UI 到 Heartbeat 的事件怎么可靠进入系统?
下一步先补 UI 到 Heartbeat 的接入层,再把事件放进 Raw MQ。

图 2:统一工作台先用 SDK、WS-API 和 Frontier 承接浏览器里的动作事件,再交给 Heartbeat 和 Raw MQ。图片由 gpt-image-2 生成。状态计算留到下一层。
前端上报的内容不只有定时心跳,还有点击、输入、鼠标、URL 变化、打开工单、回复工单、完成工单、转交工单等动作。
这些动作的含义不同,但它们有一个共同点:都只能说明“这个 agent 在某个时间点发生过动作”。
在统一工作台里,UI 到 Heartbeat 之间还有一层长链通道:
1 | Agent UI |
Workbench SDK 在一个 window 内维护一个长链接。业务模块按 module + entity 注册,deviceID 标识这条通道。module + entity 的粒度要控制好,粒度太细,切换 entity 时容易出现短暂未建联,推送就会掉在窗口外。
WS-API 负责初始化长链、注册/注销 module,也负责短链补偿。Frontier 负责长链通道。Frontier 异常时,端上可以通过短链轮询补未 ack 的消息,默认间隔是 30 秒,可以用配置调整。
重复消息也在这一层先处理一遍。业务后端传 ReqID,SDK 保存最近一批 ReqID 做去重;RPC 推送服务生成 msgID,长链推送和短链补偿都按 msgID 做兜底去重。业务需要顺序时,可以带 Index,排序语义由业务自己维护。
每个节点都对应一个明确的问题:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| Workbench SDK | 一个页面里多个模块共用一条客户端通道 | 连接池和客户端资源归属 |
module + entity 注册 |
事件要路由到正确业务模块,同时避免每个模块开新链路 | 命名空间和订阅边界 |
deviceID |
后端推送需要识别一个稳定的浏览器窗口 | 会话身份 |
| WS-API | 初始化、注册、注销、补偿需要统一入口 | 控制面和数据投递分离 |
| Frontier | 长链投递交给专门的通道服务 | 职责分离 |
| 短链补偿 | 长链异常时,最近消息还能补回来 | 降级路径 |
ReqID / msgID 去重 |
长链和补偿可能送到同一条逻辑消息 | 幂等 |
| Raw MQ | 用户动作突发和服务重启不能直接冲击 Compute | 背压和持久缓冲 |
| Heartbeat 接入服务 | 浏览器协议细节不能泄漏到 Compute | 适配层边界 |
Raw MQ 里的事件是输入数据。
如果消费端拿到一个 keyup 事件就直接把人标在线,拿不到事件就标异常,系统还是会误判。因为事件缺上下文:它不知道当前状态、任务数、技能组、业务线规则,也不知道这条异常是否已经处理过。
接下来才轮到业务上下文问题:这个 agent 能不能接单,规则覆盖哪些 skill group,异常是否已经处理过,哪个 region 可以发状态变更。这些不是接入层的问题,要交给 Compute 和 HCM。
这就需要下一层。
问题 3:怎么把事件变成状态判断?
我们加一个 Compute 层。

图 3:Compute 消费原始事件,回查 HCM 拿当前事实,再生成异常候选。图片由 gpt-image-2 生成。
Compute 层只做一件事:把“事件”转成“候选状态变更”。
常见的事件类型可以分成几组:
| 类型 | 含义 |
|---|---|
1 |
在线心跳 |
2 |
工作状态变化 |
3-12 |
打开、回复、完成、转交、升级工单等业务动作 |
100-105 |
点击、输入、鼠标、切状态、呼出、URL 变化 |
3001 |
no action 15 min 计算结果 |
3002 |
abnormal 15 min 计算结果 |
3003 |
not on app |
4000 |
Rule Config V2 计算结果 |
Compute 收到原始事件后,不直接写状态。它先问 HCM:
1 | GetWorkStatus(agent_id, tenant_id, channel) |
HCM 返回当前工作状态、任务数、技能组关系等信息。Compute 再结合规则判断这个 agent 是否进入异常候选。
这一步的核心是把判断拆开:
1 | 事件 + 当前事实 + 规则 = 候选 |
候选只是待确认结果。比如一个 agent 在 10:00 后没有动作,Compute 在 10:10 认为他可能要转异常。但 10:10:01 他可能刚接到新任务。最终能不能改状态,还要由 HCM 再读一次事实。
Compute 层的边界要硬。它负责算候选,不负责提交最终状态。
版本 2 引入这些东西,各自有明确边界:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| Compute | 原始事件需要业务解释,才能影响状态 | 领域服务边界 |
GetWorkStatus 读取 |
候选判断需要当前任务数、状态和技能组事实 | 先读事实再决策 |
| 候选状态变更 | 计算层不能直接写最终事实 | 命令暂存 |
| 规则配置 | 状态规则变化快于服务代码 | 策略和代码分离 |
| HCM 二次校验 | Compute 产出候选后,事实可能已经变了 | 乐观校验 |
问题 4:怎么计算“多久没有动作”?
很多心跳规则都带时间窗口:
| 场景 | 系统动作 |
|---|---|
ONLINE 且 30 秒没有任务 |
自动转成 IDLE |
ONLINE / IDLE / BUSY 超过 8 分钟无操作或不在工作台 |
发提醒 |
ONLINE / IDLE / BUSY 超过 10 分钟无操作或不在工作台 |
自动转成 ABNORMAL |
ABNORMAL 再持续 10 分钟无操作或不在工作台 |
自动转成 OFFLINE |
最直觉的实现是给每个 agent 建一个 timer。
这个方案很快会变麻烦。进程重启时 timer 丢失,扩缩容时 timer 分散到不同实例,跨 region 切流时还要处理 timer 归属。对几万个 agent 来说,进程内 timer 会把复杂度绑到实例生命周期上。
我们用 Redis zset 表达时间窗口。

图 4:候选 agent 按事件时间写入 Redis zset。图片由 gpt-image-2 生成。Cron 扫描到期数据,再把异常消息送回状态更新流程。
Redis 里维护几类队列:
1 | agent_no_action_for_8_min |
score 存事件时间戳。到期扫描时,用 zrangebyscore 取出 now - threshold 之前的 agent。
这个结构有几个好处:
| 需求 | zset 怎么处理 |
|---|---|
| 按时间窗口判断 | score 存 timestamp |
| Compute 重启后保留候选 | 候选状态在 Redis |
| 多套规则同时存在 | 不同规则拆到不同 zset |
| 避免重复提醒 | agent/message/rule 维度加锁 |
| 支持灰度和回滚 | 队列、规则、IDC 开关走配置 |
Cron 每 2 秒扫一次队列。扫描之前先检查当前 IDC 是否允许发送,再抢一个短 TTL 的全局锁,避免多个实例重复发同一批异常消息。
这里有个容易踩坑的点:业务窗口和防重锁时间要分开设置。
比如 10 分钟无操作触发提醒,业务窗口是 600 秒,但 agent 维度的防重锁可以设成 840 秒。这样同一阶段可以减少刷屏,后续 ABNORMAL -> OFFLINE 也还有计算机会。
版本 3 引入的是时间窗口计算层:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| Redis zset | 几万个 timer 不应该绑在进程内存里 | 状态外置 |
| timestamp score | 到期候选需要按时间高效扫描 | 按时间建索引 |
| 多条规则队列 | 不同窗口和规则不能共享隐式状态 | 工作分区 |
| Cron scanner | 到期任务要从请求流里拆出来执行 | 定时 worker |
| 全局 TTL 锁 | 多实例可能扫到同一批队列 | 租约协调 |
| 防重锁窗口 | 重复提醒控制要和业务阈值分开 | 幂等窗口 |
问题 5:谁可以写最终状态?
Compute 算出来的是候选。最终状态只能由 HCM 写入。
原因很简单:HCM 才拥有 agent、skill group、任务数、当前状态和状态变更日志。状态写入必须集中,否则下游系统会看到多个版本的事实。
座席状态本身是一组枚举:
1 | ONLINE(1000) |
HCM 消费异常候选后,会重新读取当前状态,然后做二次判断:
- 当前状态是否还匹配规则。
- 当前任务数是否允许切换。
- 当前 agent 是否属于规则覆盖的 skill group。
- 当前 region 是否允许发送状态变更。
- 这次异常是否已经通知或处理过。
规则配置可以长这样:
1 | { |
这段配置同时影响状态变更和通知。Compute 的 TCC、Redis 队列和 HCM 规则共同约束最终行为。
这套设计里,HCM 的职责很清楚:
- 拒绝过期候选。
- 根据当前事实判断能不能改状态。
- 写 status table。
- 写
unified_work_status_log。 - 把状态变化交给下游传播链路。
版本 4 的第一部分是建立状态权威:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| HCM 统一写状态 | 多个写方会制造多个状态事实 | 单写者 / 事实源 |
| 状态枚举 | 调用方需要稳定的状态语义 | 显式领域模型 |
| 规则覆盖校验 | 规则只覆盖指定状态、技能组和业务范围 | 策略执行 |
| 任务数二次校验 | Compute 产出候选后,派单事实可能已变化 | 提交时一致性 |
| 状态日志 | 状态变化需要审计和下游回放上下文 | 追加式历史 |
问题 6:下游怎么拿到一致的状态?
状态写进 HCM DB 后,还要继续传播给下游。
WFM、RouteQueue、数据侧和其他业务通常通过状态变更消息更新自己的视图。
最终链路长这样:

图 5:最终链路保留了前面几个版本的组件,但职责被拆开:事件接收、候选计算、时间窗口、状态写入、状态传播。图片由 gpt-image-2 生成。
状态传播走的是:
1 | HCM UpdateWorkStatus |
这个设计让所有下游都从 DB 变更事实出发。代价是链路变长了,任何 binlog handler、事件分发、MQ 堆积或下游消费延迟,都会让用户看到旧状态。
我们踩过这个坑。
一次 US-TTP 事故里,WFM 的 Omni-channel view 没有反映实时状态。排查时 HCM DB 里的 agent 已经 offline,HCM 到 WFM 的消息看起来也发成功了。
最后问题出在更前面:binlog 到 HCM MQ 链路堆了约 300k 条消息。work status handler 没有变慢,另一个依赖 ES 的 handler 超时了。多个 handler 共用一条消费链路,慢 handler 把其他状态传播一起拖住了。
这个事故之后,我们补了 handler 维度耗时指标,也把“消费 binlog 后直接发 RMQ,减少中间事件分发阻塞”列进改造项。
这类问题在设计阶段就应该被问出来:哪个 handler 可以拖住主链路?哪个消费组有共享故障面?哪个指标能说明下游真的收到状态?
版本 4 的第二部分是状态传播边界:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
status_table |
下游需要已提交事实,不能消费候选 | 持久事实源 |
| DBus / binlog | 消费者要跟随 DB 提交,又不能耦合写 RPC | 变更数据捕获 |
| status change MQ | WFM 和 routing 要异步消费状态事实 | 事件驱动传播 |
| handler 指标 | DB 写成功后,慢 handler 仍然可能拖住状态传播 | 分段可观测 |
| 消费隔离改造 | 无关 handler 不能阻塞工作状态传播 | 故障域隔离 |
问题 7:跨 region 切流时,事件怎么不断?
前面的链路在单 region 内已经能工作。新的问题来自容灾和切流。
Heartbeat 事件是状态计算的输入。切流时,如果流量调度先把请求打到目标 region,但目标 region 的 Raw MQ 里没有前一段时间的事件,Compute 会缺少时间窗口里的上下文。结果可能是 agent 刚切过去就被判断成无操作,或者异常计算延迟一段时间才恢复。
所以跨 region 的第一层改造是 MQ mirror。

图 6:Raw MQ 在 region 之间做 mirror。图片由 gpt-image-2 生成。目标 region 在接管流量前,已经有一份可消费的事件流。
这层解决的是事件连续性。
1 | Region A Raw MQ <-> Region B Raw MQ |
切流前,两个 region 都能拿到最近的心跳事件。真正调度流量时,目标 region 不需要从空队列开始计算。它已经能看到 agent 最近的点击、输入、心跳、工单动作和状态事件。
切流 SOP 也围绕这条链路设计:
- 先看当前 region 的 Heartbeat、HCM、Raw MQ、binlog MQ、status MQ 指标。
- 用条件化调度切一小段流量,比如 2%。
- 看
from_dc维度,确认目标 region 收到预期流量。 - 看 mirror 后的 Raw MQ lag、错误率和消费速率。
- 异常时删除调度配置,把流量切回。
MQ mirror 让事件更难丢。新的问题也随之出现:两个 region 都可能看到同一条事件。
事件重复本身还好,真正危险的是重复触发状态变更。比如同一条 no action 候选在两个 region 各发一次,HCM 可能收到两条异常消息,下游也可能收到两次状态变化。
所以下一层要处理重复。
版本 5 引入的是跨 region 事件连续性:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| MQ mirror | 目标 region 接流前要先拿到最近事件 | 复制日志 |
from_dc 维度 |
切流时要证明流量和事件进入了哪个 region | 可追踪来源 |
| lag / 消费速率检查 | 目标事件流追上前不能贸然接流 | 就绪门禁 |
| 小流量切片 | region 切换要先有可回滚的小步 | 灰度切流 |
| 删除调度配置回滚 | 切流失败不应该依赖代码回滚 | 运维控制面 |
问题 8:mirror 之后,重复事件怎么处理?
重复事件要分两段处理。
第一段在发送侧:只有一个 region 能发送异常消息。
第二段在状态侧:HCM 写状态前再读一次当前事实,做幂等判断。

图 7:mirror 保证事件连续,active region 和 dedupe lock 控制状态变更只提交一次。图片由 gpt-image-2 生成。
Compute 可以在两个 region 都消费事件,但 Cron 扫描到期队列时会先检查 active_idc:
1 | active_idc = true -> can emit exception message |
发送前再加一层 dedupe lock。锁的粒度不能太粗,否则不同规则会互相挡住;也不能太细,否则同一阶段的重复消息挡不住。比较合适的是把 agent、规则和时间窗口放进同一个去重键:
1 | agent_id + rule_id + window_start |
Redis zset 也会帮忙收敛一部分重复候选。相同 member 重复写入时,后写入会更新 score。Cron 真正发消息时,还要按 agent/rule/window 抢锁。
HCM 是最后一道保护。
收到异常消息后,HCM 重新读取当前状态、任务数、skill group 归属和规则配置。只有当前事实还满足规则,才写 status_table 和 unified_work_status_log。如果 agent 已经有任务,或者状态已经被别的路径改走,这条候选会被丢掉。
跨 region 之后,状态链路多了三类开关:
| 开关 | 作用 |
|---|---|
| 流量调度 | 控制请求进哪个 region |
| MQ mirror | 控制事件是否跨 region 复制 |
active_idc |
控制哪个 region 可以发送异常状态消息 |
这三个开关要分开:请求入口、事件复制、状态发送权可以按不同节奏切换。这样切流、回滚和演练才有操作空间。
版本 6 把 mirror 后的事件变成安全状态变更:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
active_idc |
两个 region 都能算,但只能一个 region 发状态变更 | leader 归属 |
| agent/rule/window 锁 | 同一个到期候选可能被看见多次 | 幂等键 |
| HCM 二次校验 | 重复或过期候选不能写出旧状态 | 防御式写校验 |
| 流量 / mirror / emit 三套开关 | 请求、事件复制、写权限切换节奏不同 | 正交控制面 |
问题 9:TT 和 IES 拆分时,怎么不打断老链路?
HCM/Heartbeat 后来同时服务 IES 和 TT 侧业务。两边共用 HCM、Heartbeat、DB、MQ 和 routing 更新链路时,发布、容量和容灾都会绑在一起。
TT 内容侧要拆出去,最简单的做法是新建 TT HCM、TT Heartbeat、TT DB 和 TT MQ,然后让上游一次性切走。
这个方案风险很高。HCM 连着登录、状态更新、skill group、任务数、路由、WFM 和 binlog 传播。一次性切干净,任何一个字段、过滤条件或 MQ 消费组出错,都会影响接单和状态同步。
所以第一阶段先做共存。

图 8:TT upstream 先迁到 TT HCM,写入事实仍由 IES HCM 承担。图片由 gpt-image-2 生成。dsyncer 和转发逻辑保留回滚空间。
第一阶段的链路是:
1 | TT upstream |
这一步的目标很明确:先把调用入口迁走,写入事实先留在旧链路。
为了让旧 HCM 知道哪些数据要转给 TT,需要加 ownership filter。判断路径是:
1 | agent_id |
判断结果写 Redis cache。cache miss 时查 DB,拿到 access party 后再写回。工作状态、agent-skillgroup 关系和 routing 更新都可以按这个结果过滤。
这层改造带来的好处是可回滚。TT HCM 出问题,可以继续走 IES 原链路。filter 出问题,也可以通过配置关掉转发,先保住旧路径。
版本 7 是迁移桥:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| TT HCM 入口 | 上游可以先迁入口,不必一次切完写链路 | 绞杀者模式 |
| 转发 RPC 到 IES HCM | 第一阶段仍由旧事实源写状态 | 兼容适配器 |
| dsyncer | TT 侧先建立本地读模型,写入仍留在旧路径 | 数据复制 |
| ownership filter | TT 和 IES 数据要按 agent 归属拆开 | 按领域归属路由 |
| Redis ownership cache | 归属判断是高频读 | read-through cache |
| 配置开关 | 迁移出问题要能不发版回滚 | feature flag / 回滚杠杆 |
问题 10:什么时候可以真的拆开?
共存阶段跑稳之后,才进入第二阶段。
第二阶段要把写入、事件、MQ 和 routing 更新都拆开:

图 9:拆分完成后,TT 和 IES 各自拥有 upstream、HCM、DB、MQ 和 routing 更新链路。图片由 gpt-image-2 生成。
拆分动作包括:
- TT HCM 停止把 RPC 转发到 IES HCM。
- IES 到 TT 的 dsyncer 停止。
- IES heartbeat/MQ 停止消费 TT agent 消息。
- TT routing 只消费 TT 侧状态更新。
- IES routing 只消费 IES 侧状态更新。
拆完后,旧 HCM 侧减少约 3,000 个 agent 和约 2,000 QPS。
这里要验证两件事:服务已经拆出来,两边的状态事实也各自闭合。
- TT agent 的心跳只进入 TT 侧。
- TT agent 的状态只由 TT HCM 提交。
- TT 状态变化只进 TT routing/WFM/data 链路。
- IES 侧保留原有路径,不被 TT 发布和切流影响。
这时 TT/IES 拆分才算完成。
版本 8 是把迁移桥拆掉:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| 停止转发 | TT 写入不再依赖 IES 可用性 | 服务归属 |
| 停止 dsyncer | 临时同步链路不能永久留在系统里 | 迁移产物下线 |
| TT / IES 独立 MQ | 事件要留在自己的业务边界内 | bounded context |
| 独立 routing 消费者 | 路由更新不能跨 ownership 边界 | 消费者归属 |
| 闭合性校验 | 拆分完成要证明事实和副作用都在本侧闭合 | 不变量验证 |
问题 11:前端动作没进来,系统怎么发现?
状态计算依赖输入事件。后端 RPC 全绿,只能说明后端还活着,说明不了用户动作真的进了 Heartbeat。
GCP -> NO1A 切流后,出现过 agent 频繁 abnormal。人还在工作台操作,系统却判断他长时间没动作。最后查到的问题在工作台长链路配置:keyup / keydown 事件没有稳定进入 Heartbeat。
HCM 错误率覆盖不到这段输入链路。状态计算前面要加一层输入健康检查。

图 10:状态计算之前先确认用户动作事件稳定进入系统。图片由 gpt-image-2 生成。Workbench SDK、WS-API、Frontier、Heartbeat 接收、Raw MQ 都要上报输入健康指标。
输入健康可以按几个维度看:
| 指标 | 用途 |
|---|---|
event_rate{event_type, region, channel} |
看点击、输入、心跳等事件是否突然下降 |
client_lag |
看客户端动作到服务端接收的延迟 |
frontier_error_rate |
看长链通道是否异常 |
ws_register_failure |
看 module/entity 注册是否失败 |
short_poll_lag |
看短链补偿是否跟得上 |
raw_mq_lag |
看事件进入 MQ 后是否堆积 |
synthetic_action |
每个 region 定时打模拟动作,验证链路能跑通 |
no_action_ratio |
看异常候选是否突然集中在某个 region、版本或 channel |
工程上更稳的做法是把输入健康接进规则层。
如果某个 region 或工作台版本的输入健康已经异常,自动 abnormal 规则应该降级:延长窗口、暂停自动切异常,或者只发提醒。等输入链路恢复后,再回到正常计算。
这样做会牺牲一部分自动化程度,但能避免“因为采集链路断了,把正在工作的人切成异常”。
版本 9 补的是输入可观测性:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| 输入健康指标 | 后端成功率不能证明用户动作已经进来 | 端到端可观测 |
frontier_error_rate / ws_register_failure |
长链和注册失败要有自己的信号 | 分层遥测 |
short_poll_lag |
降级路径也要被度量 | fallback 可观测 |
| synthetic action | 低流量场景下,被动指标可能看不出链路断裂 | 主动探测 |
| 规则降级 | 输入不健康时,自动化规则要先收住 | 业务规则熔断 |
问题 12:HCM 已经写状态,下游还是旧状态怎么办?
状态提交成功以后,下游还要拿到同一个事实。这里出过一个很典型的事故:US-TTP ES down 时,WFM 的 Omni-channel view 没有反映实时状态。
排查时,HCM DB 里的 agent 已经 offline,HCM 到 WFM 的消息看起来也发成功了。最后发现 binlog 到 HCM MQ 堆了约 300k 条消息。work status handler 本身没慢,另一个依赖 ES 的 handler 超时了。多个 handler 共用一条消费链路,慢 handler 把工作状态传播拖住了。
这个问题把 V4 的状态传播继续往前推一层:状态变更要有独立车道。

图 11:binlog 下游按业务语义拆成独立 handler。图片由 gpt-image-2 生成。Search handler 变慢时,work status handler 仍然可以把状态送到 WFM 和 routing。
更好的目标形态是把状态变化当成一等事件处理:
1 | HCM transaction |
status_change_outbox 和状态写入放在同一个事务里。dispatcher 只负责把 outbox 里的状态事件送到状态 MQ。
Search、analytics、audit 这些消费者可以继续消费 binlog 或订阅自己的事件流,但它们不应该和 work status propagation 共用同一个阻塞点。慢 handler 要进入自己的 retry / DLQ,不能拖住 WFM 和 routing。
这层还要补端到端指标:
| 指标 | 说明 |
|---|---|
status_commit_to_mq_latency |
HCM 写 DB 到状态消息发出的延迟 |
mq_to_wfm_latency |
状态 MQ 到 WFM 消费完成的延迟 |
handler_lag{handler} |
每个 handler 自己的堆积 |
handler_error_rate{handler} |
每个 handler 自己的错误率 |
downstream_state_age |
下游看到的状态距离 HCM 提交多久 |
状态系统最怕“主库已经对了,下游还错着”。只有把提交、出站、消费分开看,才能知道慢在哪一段。
版本 10 隔离的是状态传播链路:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
status_change_outbox |
DB 提交和出站事件要一起成功或一起失败 | 事务 outbox |
work_status_dispatcher |
工作状态不能排在无关 handler 后面 | 专用 worker |
| handler 独立 retry / DLQ | 慢消费者需要自己的恢复通道 | bulkhead 隔离 |
| 端到端延迟指标 | 下游旧状态要能定位到具体段 | pipeline 可观测 |
| 对账方向 | 下游状态可能偏离 HCM 事实 | 最终一致性修复 |
问题 13:热读接口把 HCM 打爆怎么办?
GetWorkStatus 是这条链路里的热接口。Compute 要查它,上游也要查它。一次 OOM 事故里,downstream error rate spike 和 GetWorkStatus 流量峰值对齐。
这说明状态系统还需要容量防线。

图 12:GetWorkStatus 前面加 quota,内部按调用方隔离线程池,后面用短 TTL cache 和自动扩容信号保护 DB。图片由 gpt-image-2 生成。
这里的目标是把不同调用方隔开。单个上游出问题时,故障范围停在自己的预算里。
GetWorkStatus 至少需要这几层保护:
| 防线 | 作用 |
|---|---|
| per-caller quota | 单个上游打爆时,先限制这个上游 |
| bulkhead pool | Compute、WFM、管理台等调用方分开线程池 |
| read cache | 短 TTL 缓存状态,挡住重复读 |
| stale read budget | 非写路径允许读到短时间内的旧状态 |
| circuit breaker | DB 或依赖异常时快速失败 |
| autoscale signal | 按 QPS、heap、GC、p99 延迟扩容 |
Heartbeat Compute 侧也要配合。比如同一个 agent 在短时间内连续上报动作,不需要每条事件都 RPC 查一次当前状态。可以按 agent 做小窗口合并,或者读取本地/Redis 状态快照,再把最终提交交给 HCM recheck。
这层改造把 GetWorkStatus 从“谁来都查 DB”变成“有预算、有隔离、有降级的状态读服务”。
版本 11 把热读接口变成受保护的服务:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| per-caller quota | 单个上游可能打爆共享状态读 | 公平性和准入控制 |
| bulkhead pool | 不同调用方不能抢光彼此 worker | bulkhead 隔离 |
| 短 TTL cache | 重复读不能每次都打 DB | cache-aside |
| stale read budget | 部分读路径可以用新鲜度换可用性 | 显式一致性预算 |
| circuit breaker | 依赖坏掉时要快速失败 | 故障收敛 |
| Compute 侧合并 | 同一 agent 短时间多事件可以合并读 | 请求合并 |
问题 14:查询改动怎么安全上线?
HCM 4.3 的 DB query rollback 暴露的是发布防线。
canary 阶段看起来正常,ROW 后出现新错误日志。原因是两个 LEFT JOIN 放大了 count SQL 的数据量,最后打到 DB timeout。
“先 canary 再全量”覆盖不了这类问题。canary 流量小,数据分布也可能偏,复杂查询要单独过成本检查。

图 13:查询改动先经过 feature flag、canary、shadow query、EXPLAIN 和慢 SQL 监控。图片由 gpt-image-2 生成。异常时切回 old query。
查询发布可以按这条路径走:
1 | query change |
shadow query 不参与线上返回,只比较结果和成本。发布系统要看几类信号:
| 信号 | 动作 |
|---|---|
EXPLAIN row estimate 超阈值 |
拦住发布 |
| shadow query 结果不一致 | 拦住发布 |
| p99 查询耗时超过预算 | 降级或回滚 |
| 新错误日志增加 | 回滚 |
| DB timeout 增加 | 回滚 |
对查询路径来说,feature flag 不能只开关整个功能。更实用的是按 filter 字段选择 old query 或 join query:
1 | simple filter -> old query |
这样某个复杂 filter 出问题时,可以只回退这条查询路径,不影响其他查询。
版本 12 给数据访问加发布门禁:
| 节点 / 改动 | 解决的问题 | 软件工程概念 |
|---|---|---|
| feature flag | 查询行为需要运行时回滚 | 渐进式发布 |
| canary | 新查询先吃小流量 | 受控暴露 |
| shadow query | 结果和成本可以先验证,不影响线上返回 | dark launch |
EXPLAIN 成本预算 |
昂贵查询计划要在进流量前拦住 | 静态成本门禁 |
| 慢 SQL / 错误预算 | 运行时成本可能和 canary 预期不同 | 自动回滚信号 |
| filter 级 fallback | 一个复杂 filter 出问题,不该回滚所有查询 | 细粒度 kill switch |
后续还要解决什么?
上面的 V9 到 V12 把系统推成一个状态平台。输入链路、计算链路、提交链路、传播链路、容量链路、发布链路都要能被单独观测和单独降级。
后面还可以继续扩:
| 问题 | 扩展方向 |
|---|---|
| 规则变多以后怎么验证 | 做 rule simulator,用历史事件回放新规则,看会影响多少 agent |
| 状态错误怎么快速定位 | 做 per-agent timeline,把输入事件、候选、zset、HCM 提交、下游消费串起来 |
| 多 region 会不会脑裂 | 定期演练 active_idc 切换,检查重复消息和丢消息 |
| 下游状态长期落后怎么处理 | 增加状态 reconciliation job,把 WFM/routing 与 HCM 事实对账 |
| 热读缓存会不会读脏 | 给缓存结果加版本号和过期预算,写路径仍由 HCM recheck 收口 |
最终设计
回到最开始的问题:怎么知道一个运营是否在线?
回到开头那张最终目标图,可以按两条线看:数据面和控制面。
数据面负责把用户动作变成状态事实:
1 | Agent UI |
控制面负责让这条链路在异常时还能收住:
- 前端负责上报动作和心跳。
- Workbench SDK 负责长链单例、断链重连、短链补偿和端上去重。
- 输入健康确认动作事件真的进入 Heartbeat。
- MQ mirror 保留跨 region 事件连续性。
- Redis zset 和 dedupe lock 控制候选重复。
active_idc控制哪个 region 能发送异常消息。- HCM recheck 收口最终状态提交。
- Outbox 和 handler 隔离保护工作状态传播。
- quota、cache、bulkhead 和 circuit breaker 保护热读接口。
- query gate 控制复杂查询发布。
- TT/IES ownership 控制 agent、skill group、MQ 和 routing 更新归属。
这个设计看起来比第一版复杂很多。每一层都是被上一个问题推出来的:
| 版本 | 新问题 | 新增设计 |
|---|---|---|
| V0 | 怎么记录最近活跃 | last_seen_at |
| V1 | UI 到 Heartbeat 的事件怎么可靠进入系统 | Workbench SDK + WS-API + Frontier + Raw MQ |
| V2 | 事件怎么变成状态判断 | Compute + GetWorkStatus |
| V3 | 多分钟窗口怎么计算 | Redis zset + Cron |
| V4 | 谁写最终状态,下游怎么拿事实 | HCM + DB/binlog/status MQ |
| V5 | 跨 region 切流时事件怎么不断 | MQ mirror |
| V6 | mirror 之后重复事件怎么处理 | active_idc + dedupe + HCM recheck |
| V7 | TT 拆分怎么保留回滚路径 | TT HCM 转发 + ownership filter + dsyncer |
| V8 | 怎么把 TT/IES 真正拆开 | 独立 HCM/DB/MQ/routing 链路 |
| V9 | 前端动作没进来怎么发现 | input health + synthetic action |
| V10 | 下游状态被慢 handler 拖住怎么办 | handler isolation + outbox + DLQ |
| V11 | 热读接口把 HCM 打爆怎么办 | quota + cache + bulkhead + load shedding |
| V12 | 查询改动怎么安全上线 | feature flag + shadow query + auto rollback |
最开始的问题只是“这个人还在不在线”。最后系统回答的是一组更具体的问题:这个 agent 最近有没有动作,输入事件链路是否健康,当前有没有任务,属于哪个 skill group,规则是否覆盖他,哪个 region 可以发异常,状态能不能提交,下游是否拿到了同一个事实,热读接口是否在预算内,查询发布是否能回滚,TT 和 IES 的 ownership 是否已经分开。