超时升级:一条 SQL 统一两条业务线

告警有个期限,到期没人理,级别就往上抬——需求一句话就说完了。但落地时它分裂成两条业务线:人工确认线的期限在告警侧(告警产生时刻 + N 分钟),自动工单线的期限在 BPM 侧(审批节点配置的处理时限)。两版方案迭代下来,最终的形态是一条 UPDATE 语句覆盖两条线,而中间踩掉的几个坑,比 SQL 本身更值得记录。

两条业务线汇入同一条扫描 SQL
图 1:期限来源不同的两条线,在”快照”这一步被归一化成同样的两个字段。

先拍死需求:三套注释,两套是错的

动手前有个前置问题差点把方向搞反:告警级别到底是几级、哪头高?用户以为是 3 级;代码里的枚举是 4 级但注释写”3=严重”;另一个模块的实体注释又是”1紧急 2重要 3一般”——三套说法,两个方向相反。

最后敢拍板的依据只有一个:渲染端实际使用的运行时字典。查字典表,真实值是”0一般 / 1普通 / 2重要 / 3紧急”,数值越大级别越高。平台里多个模块对同一个概念各写各的注释时,注释不是文档,运行起来的数据才是。顺带还挖出一个既有 bug:统计接口拿级别字段去比英文串,永远匹配不上、统计恒为 0——记录在案,另行治理。

第一版的教训:状态位、接线与防御性过滤

第一版方案走”bpm 扫描超时 → 回调 warning 升级”的路线,给超时表加 is_escalated 标记位保证同节点只升一次。这一版在需求确认和 review 阶段各炸出一个”上线才会发现”的问题,最终整体被回退,但三个教训留了下来。

教训一:挂在长方法尾部的新逻辑,可能被提前 return 短路。 最初把升级调用挂在宿主方法尾部,直觉是”先干主流程再干附加的”。但宿主方法有两处提前 return,全在尾部之前——升级代码永远执行不到。最坑的是单测全绿:测试直接调新方法本身,方法没错,坏的是它和入口的接线。修复方式是把测试改成走真实入口,先红后绿。挂新逻辑前,先扫一遍宿主方法的全部 return 路径。

教训二:防御性过滤会把合法数据筛光。 review 建议扫描 SQL 补一个软删过滤 delete_flag = '0'——项目通用惯例,看着毫无问题。加上后真库实测:扫描结果从 9 条变成 0 条。查数据才发现这张表现存行的 delete_flag 全为 NULL:全量 insert 时实体未初始化的字段以 NULL 入库,盖掉了列默认值。加过滤条件前先查真实数据分布——“防御性”条件筛掉全部合法数据时比 bug 更隐蔽:不报错,只是永远查不出结果。

教训三:状态位是一致性负债。 is_escalated 这类标记位,多一个就多一份”标记和事实不同步”的风险。这直接促成了第二版的思路转向。

第二版:用”事实”当标记,而不是新增标记

第二版换了一个问法:能不能不加任何状态位,让”该不该升”这件事由数据里已经存在的事实回答?

关键动作是把期限和目标级别快照到告警记录上。工单发起成功后,warning 侧回查 BPM 的超时记录拿到截止时间,写入告警的 confirm_deadline,并把目标级别在当时就算死(当前级别 + 1)。之后扫描 SQL 不需要懂任何业务语义,只负责比较两个现成字段:

1
2
3
4
5
6
7
8
9
10
11
UPDATE alarming_record
SET confirm_status = CASE WHEN confirm_status='0' THEN '2' ELSE confirm_status END,
alarm_level = CASE WHEN CAST(alarm_level AS UNSIGNED) < CAST(timeout_target_level AS UNSIGNED)
THEN timeout_target_level ELSE alarm_level END,
update_time = NOW()
WHERE delete_flag='0' AND confirm_deadline IS NOT NULL AND confirm_deadline <= NOW()
AND timeout_target_level IS NOT NULL
AND ( confirm_status = '0' -- 手动线:待确认且超时
OR ( confirm_status = '1' AND work_order_id IS NOT NULL -- 自动线
AND alarming_status = '1' AND confirm_time IS NULL
AND CAST(alarm_level AS UNSIGNED) < CAST(timeout_target_level AS UNSIGNED) ) )

这条 SQL 里藏着四个决策,每个都有出处:

level < target 自带幂等。 升过级的行级别已等于目标值,下一轮扫描自动掉出 WHERE——不需要 is_escalated 标记位。用”级别已达标”这个天然事实当标记,比新增状态位干净,这是第一版回退换来的认知。

“确认超时”状态只落手动线。 自动行的 confirm_status 本来就是”已确认”,改成”确认超时”会污染人工确认的语义,前端确认列就乱了。CASE 表达式的 ELSE 分支原样保留,天然实现。

alarming_status = '1' 是评审补的口子。 原始版本没有这行。评审发现:用户从 BPM 侧直接办结工单时,回调已把告警置为”已处理”但 confirm_time 是空的——没这行,到期会误升级一条已处理的告警。宁可少升,不制造用户看不懂的灵异现象。

“有人看过就不升级”是两线统一的豁免。 手动线靠确认状态离开初始值;自动线靠 confirm_time 有值或告警被处理。一条 SQL 里两套豁免各自归位。

快照的价值不止是给 SQL 减负。BPM 侧的超时记录会随审批节点流转删除重建,如果扫描时实时去查,计时基准一直在抖动;快照到告警记录上,正好把这个抖动隔离在系统边界之外。

为什么不在 service 里”查-判-改”?

四条业务边界(到顶、已处理、已删、不存在)全部压进 WHERE 之后,影响行数 0 就是”不该升”、1 就是”升了”——单条语句原子生效。如果拆成”先查询、再判断、再更新”三步,判断和更新之间状态可能变(并发、重复调度),还得自己铺一层判空分支。把守卫写进 WHERE,是让数据库的更新语义替你兜并发。

明确不做的

设计里同样有分量的是三条”不做”:BPM 侧撤销工单,warning 无感知、告警仍可能升级(用户拍板接受);只快照首个审批节点的期限,不跟随后续节点重新计时;节点没配审批时限就不参与升级。把边界写在明处,比假装系统全能要诚实得多。

验证

单测 22/22(TDD 先红后绿,6 个快照用例覆盖决策链的每个早退分支);E2E 六项矩阵全过,包括”重复扫描 0 行”这个幂等性实证,和评审后补测的”已处理告警不升级”。

参考资料