让告警打电话:重拨、升级与"算不算叫到人"的判定
飞书群里的告警,深夜没人看。P0 需要能把人从床上叫起来的通道,也就是打电话。
真做起来才发现,”打个电话”是这套系统里最简单的一步。难的是后面三件:打了几通算尽力了?什么情况算叫到人了?没人接怎么办? 这三个问题不定义清楚,电话告警只是一个更吵的通知渠道。
结论先放前面:判定标准必须基于电话系统给的挂断原因,不能只看”接没接通”——拒接和响到底没接是两件事,前者说明人看到了。编排不能塞进一个 Lambda(三个人的升级链就超过 15 分钟硬上限)。去重不需要额外存储(拿状态机执行名当幂等键)。以及:这套东西只能靠真拨电话来验证,我们三轮演练打出来六个 bug,全都是单元测试照不到的地方。
1. 什么算”叫到人了”
这是整个设计的核心,也是最容易糊弄过去的地方。
朴素做法是”打通了就算”。但电话打通有很多种,含义完全不同。Amazon Connect 在通话结束后会给一个 DisconnectReason,它比”通没通”信息量大得多:
| 现象 | 典型时长 | DisconnectReason | 算叫到人吗 |
|---|---|---|---|
| 接起来按了 1 | — | 流程置 answered=true |
确认 |
| 拒接(手动挂掉) | 约 30–40s | TELECOM_UNANSWERED |
算——他看到了,主动挂的 |
| 响到底没接 | 约 55–60s | TELECOM_ORIGINATOR_CANCEL |
不算 |
| 接通但没按 1(多半是语音信箱) | 约 20s | CUSTOMER_DISCONNECT |
不算 |
| 根本打不出去 | — | TELECOM_PROBLEM 等 |
不算 |
第二行是关键。拒接是一个信号:手机在他手上,屏幕亮了,他按了挂断。这跟手机在客厅充电、响了一分钟没人理,是完全不同的两件事。前者不需要再升级给下一个人,后者需要。
只看”是否接通”会把这两种合并,代价是每次有人手滑挂断,就白白惊动下一个人。
三通,只看第三通
给每个值班人最多打 3 通,间隔 1 分钟、2 分钟。判定规则:
- 任意一通按了 1 → 确认,整轮结束;
- 第 3 通(最后一通)是拒接 → 算触达,结束,不升级;
- 其余情况 → 未触达,升级给下一个人。
前两通只是重试,结果不参与判定。
这里有个已知的取舍值得写下来:有人前两通都拒接(说明明显看到了)、第 3 通刚好没接住 —— 会判成未触达并升级。想要更宽松就改成”任一通拒接即触达”。我们选了严格的那版,因为误升级的代价(多吵醒一个人)远小于漏升级(P0 无人处理)。这种取舍应该显式记下来,而不是让它变成代码里的一个偶然。
2. 编排为什么不能塞进一个 Lambda
先算时长。每个值班人:
1 | 拨1(~60s) + 等1min + 拨2(~60s) + 等2min + 拨3(~60s) ≈ 6 分钟 |
1 人 6 分钟,2 人 12 分钟,3 人 18 分钟。
而 Lambda 的硬上限是 15 分钟。2 个人勉强塞得下、毫无余量,3 个人直接超时。
就算时长够,单 Lambda 还有两个问题:
- 空等按时长计费。流程里绝大部分时间是在
Wait,等 12 分钟就付 12 分钟的钱,而这段时间它什么也没干。 - 崩了状态全丢。”现在打到第几个人、第几通”这些状态在内存里,进程一挂整个升级链断掉,而且没有任何人会知道——这是最糟的失败方式。
所以编排用 Step Functions(Standard):最长能跑一年、Wait 期间不占资源也不按时长计费、状态持久可恢复。
前面挂一个瘦 Lambda 只做四件事,跑不到 1 秒:过滤、去重、查值班人、StartExecution。有时长问题的是”编排”,不是”触发”。
如果坚决不上 Step Functions,替代方案是 “Lambda + EventBridge Scheduler”:每次拨号一个短 Lambda,下一次靠定时器触发。但你得把”第几通、第几个人”外存起来,比状态机啰嗦得多,也更容易错。
3. 去重:不需要额外的存储
告警会重复触发。同一个故障连打三轮电话,人会疯。
常见做法是上一张 DynamoDB 表记指纹。但这里有一个零存储的办法:用状态机的执行名当幂等键。
1 | 执行名 = 告警指纹 + 时间桶 例如 fp-a1b2c3-2026080615 |
Step Functions 的执行名在 90 天内唯一,同名重复启动直接报 ExecutionAlreadyExists。瘦 Lambda 捕获这个异常,就是一次去重。不需要表、不需要 TTL、不需要清理任务。
去重窗口等于时间桶粒度(按小时就是”同一告警同一小时只叫一轮”)。已知边界:桶切换处(3:59 和 4:01)会漏掉极少数,可以接受;真要精细的滚动窗口才值得上一张表。
上游 Grafana 的 group_interval / repeat_interval 本来就会稀疏化通知,两道叠起来足够了。
顺带一提,审计也不需要额外存储:终态用不同的 Succeed 状态名标记(confirmed / reached / no_ack),直接看执行历史。
4. 两条必须先想清楚的边界
只对 firing 启动。 Grafana 恢复的时候也会发通知,不过滤的话故障恢复也会打电话把人叫醒。瘦 Lambda 里 status != firing 直接丢弃。
no_ack 不能静默结束。 所有人都升完了还是没人确认,这是”P0 无人处理”,是整个系统最需要报警的时刻,绝不能让状态机安静地走到 Succeed。终态发一个兜底通知:群里 @所有人、拉更高层的号、降级到别的渠道,随你定,但必须有。
5. 三轮真实演练打出来的六个 bug
这套东西只能靠真拨电话验证。下面六个全是演练打出来的,单元测试一个也照不到。
① 一个人按 1,别组的升级链被掐断
最初的收尾条件是 AnyAck——任何人按 1 就结束整轮。多组并行的时候这是错的:A 组的人确认了,B 组的升级链直接停了,而 B 组根本还没被叫到。
改成按组结算:已确认的组退出拨号,未确认的继续升级,所有组都结算了才收尾。顺带加了 partial 这个结果状态——“部分组已响应”是真实存在的一种结局,之前只有确认/未确认两种,表达不了。
改的时候有个坑:退避之后的跳转必须回到”重新挑选未结算的组”那一步,不能直接回到”拨号”。否则已经结算的组会被重复拨打。
② 升级之后,统计数字全错
状态机里累计通数写成 $count($batch) * $attempt,而升级到下一个人时 $attempt 会重置成 1 —— 于是任何基于 $attempt 的总数,在升级后只反映最后一个人。
真实数据:第一个人三通未接、第二个人第一通接了,实际打了 4 通,记成 1 通。
阴险的地方在于:没有发生升级的时候数字是对的。日常演练大多一通就接了,所以这个 bug 存活了很久。
同期还发现另一个:记录”打给了谁”的字段记的是配置名单而不是实际拨过的人。只打了第一个人,界面上也显示”甲 → 乙”。后来拆成两个字段:called(实际拨过)和 roster(配置名单)。
③ 两层 Catch 把错误全咽了,执行还是 SUCCEEDED
埋点数据整批丢失,但状态机执行显示成功。
链路是这样的:查询通话属性的那步执行之后,$states.input 已经变成了 Connect 的返回值;下一步仍按原始入参去取 contactId,取到 undefined;报错 → 被 Catch 接住跳到下一步;那一步引用一个从未被赋值的变量 → 再报错 → 被第二层 Catch 接住跳到收尾。
两层 Catch 都写的 States.ALL,把 EvaluationFailed 一起咽了,于是整个执行一路绿灯走到 SUCCEEDED,而埋点一条没写出去。
教训:Catch: States.ALL 是个很危险的默认写法。它本意是”别让一次 API 抖动断链”,实际会把代码错误也一起吃掉。至少要把 States.Runtime / States.TaskFailed 和 EvaluationFailed 分开处理。
④ UNKNOWN 是个竞态,不是故障
看板上出现了结束原因 UNKNOWN,用户反馈”看着像系统故障”。
查下来是竞态:等待振铃是 60 秒,Connect 的振铃超时也是 60 秒。未接通话恰好在查询前 1~2 秒才结束,DisconnectReason 还没写出来。47 次通话里出现 1 次。
处理方式值得说一下。没有去猜一个结论,而是把这个值单独命名为 PENDING_AT_CHECK,界面上映射成「未取到 · 多半是响到底没接」——诚实(不断言)但有指向性(实测这种情况几乎都是响到底没接)。
同时保留了旧的 UNKNOWN 映射,文案是「未取到值」,不带诊断结论。因为状态机已经不再产出它了,它只存在于历史数据里。两个值分开映射,看板上一眼就能区分数据新旧。
⑤ 告警送不到,根因是渲染出的 JSON 非法
某个下游收不到告警。Grafana 那边显示投递成功,14 毫秒,没有任何报错。
根因是模板把多行的 summary 原样塞进 JSON 字符串,里面的裸换行让整个 payload 变成非法 JSON。接收方解析失败,但发送方看到的是 HTTP 成功。
修在模板层而不是逐条改规则,这样以后谁写多行 summary 都不会再炸:
1 | {{ reReplaceAll "\n" "\\n" (reReplaceAll "\"" "\\\"" .) }} |
换行和双引号都转义。改完把全部 54 条规则渲染一遍验证,改前有 2 条是非法的。
这件事的通用教训:“投递成功”只说明对方收下了字节,不说明对方能解析。 端到端验证要看接收方,不能看发送方的日志。
⑥ 五个会让人白忙一场的配置坑
这几个不是 bug,是 Grafana 告警本身的行为,但都会让”我明明配了电话告警”落空:
- 在规则里选了具体的联络点,会完全绕过策略树——你加的路由标签一点用没有,而且不报错。
- 带某些特定标签值的告警,会被策略树里
continue=false的路由半路截走。 - 静态的”打电话”标签配上
noData/ 执行错误的处理策略非 OK 时,数据源一故障,几十条规则会同时打电话。 - 一条规则多个实例只打一通(
group_by整组一条消息),电话里说不出是哪台、几台。 - 改标签会改指纹。 给一条正在烧的告警改标签,等于产生一条新告警——会真的打电话出去。
第 5 条我们踩过。改配置前先看有没有正在 firing 的实例,这个习惯值得养成。
6. 小结
- 判定用
DisconnectReason,拒接和响到底必须区别对待。 - 重拨/升级的编排用状态机,别塞进 Lambda:15 分钟上限、空等计费、崩了状态全丢。
- 去重可以零存储:状态机执行名 = 指纹 + 时间桶,撞名异常就是去重信号。
- 只对 firing 启动;
no_ack必须有兜底,不能静默 Succeed。 Catch: States.ALL会把代码错误一起吃掉,执行还是绿的。- 拿不到值的时候,给这个状态一个专门的名字,比塞一个
UNKNOWN好——前者能带诊断提示,后者看着像故障。 - “投递成功”不等于”对方解析成功”。
- 这套东西只能靠真拨电话验证。我们的六个 bug 全部来自三轮真实演练,没有一个是单元测试发现的。