记录一次线上告警误报的治理过程:从定位误报根因,到对比 Redis 延迟队列、MQ 延迟消息、数据库表三种载体,最终用一张日志表加一个定时任务解决。2026-10-14日上线,上线次日已有真实流量验证。
一、背景
业务链路
供应商会在低代码平台上填写配货单的发货数,每次提交都会推送到我们的内部微服务:
text
低代码平台提交 → /delivery/admin/updateShipmentNum
→ 线程池异步处理 + 分布式锁
→ 幂等校验(已发货/部分收货/已完成/已作废,直接返回)
→ 明细校验(发货数完整性)
→ 事务更新发货数
→ 异步同步库存中心
明细校验里有一条规则:任何一个明细的发货数为空,整单发货失败,同时打 ERROR 级监控日志 delivery_docs_shipment_num_is_empty,由监控平台触发告警。
问题:空值推送
上线后发现这个告警存在大量误报,且这是低代码平台的一个 bug导致的:同一张配货单会被重复触发推送。这个问题我们已反馈给平台方,对方暂时修复不了,短期内「同一单据被反复推送」不会改变,只能在我们的服务侧消化。以单据 PH202610120001 为例,1 分钟内被推送了三次:
| 时间 | 推送内容 | 结果 |
|---|---|---|
| 21:53:22 | 发货数为空 | 触发 ERROR 告警 |
| 21:53:28 | 发货数有值 | 正常发货 |
| 21:54:55 | 发货数有值 | 幂等拦截 |
这个案例就是一次典型的重复触发:空值的第一次推送直接触发了告警,几秒后第二次有值推送补齐了发货数,单据本身是正常的,但告警已经发出去了。
且我明明在低代码平台上,把发货数设置为必填了,但是供应商批量操作发货的时候,就是会发生发货数不填的情况。醉了。
另外:
- 空值推送本身零业务影响。明细校验失败后直接 return,不写任何业务数据,单据状态不受影响;
- 最终结果通常是正常的。几秒后第二次有值推送会补齐发货数,单据正常流转。
也就是说,告警描述的问题绝大多数不存在,但每次告警值班同学都要去查一遍单据,确认是虚惊一场。误报多了,告警的可信度就被消耗掉了,真出问题时反而容易被忽略。
根因
告警绑定在「单次请求」维度,捕获的是瞬时中间态。而业务上真正关心的是「这单最终有没有把发货数补上」,这是一个终态问题。拿中间态去触发终态级别的告警,误报是必然的。
二、设计方案
核心思路
把「检测时立即告警」改成「检测时记录,延迟复核后再告警」:
text
空值推送 → 写一条待复核记录(不再直接告警),check_time = 当前时间 + 10 分钟
定时任务 → 扫描已到复核时间的记录 → 查配货单主表状态
→ 仍是 pending:真问题,告警
→ 不是 pending:后续推送已补齐,标记正常,不告警
为什么不能在告警时刻查库
你可能觉得「告警时顺便查一下数据库,看单据最终状态」,但这条路走不通的:告警时刻查到的必然是「还没发货」,因为触发告警的这次推送就是空的,后续推送还没到。查出来的永远是中间态。所以「延迟」不是优化项,是方案成立的前提。
表设计
按「尽量简单」的原则,只保留业务必需的三个字段加标准五件套:
SQL
CREATE TABLE `delivery_docs_shipment_warn_log`
(
`id` bigint auto_increment comment '自增id' primary key,
`delivery_docs_no` varchar(64) not null comment '配货单单据号',
`warn_status` varchar(20) not null default 'waiting' comment '告警日志状态,waiting:待检查,passed:已检查正常,alarmed:已告警',
`check_time` datetime not null comment '复核时间(写入时间+复核延迟时间),到达该时间后由定时任务复核配货单状态',
`create_time` timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
`update_time` timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
`create_by` varchar(50) default '' not null comment '创建者',
`update_by` varchar(50) default '' not null comment '更新者',
`is_deleted` int default 0 not null comment '软删除状态,0:正常,1:已被软删除',
unique key `uk_delivery_docs_no` (`delivery_docs_no`),
key `idx_warn_status_check_time` (`warn_status`, `check_time`),
key `idx_create_time` (`create_time`)
) comment '配货单发货数告警监控日志表';
两个设计点:
uk_delivery_docs_no唯一键:同一配货单只保留一条记录。低代码平台秒级的重复空推会被数据库唯一键挡住(代码里捕获DuplicateKeyException直接忽略),天然去重;- 复核时刻单独落一个
check_time字段:定时任务的扫描条件就是一个简单的check_time <= now,不依赖额外计算。
逆向判定
复核时如何判断「这单真的有问题」?配货单状态机是 init → pending → shipped / part_received / completed / cancel。
第一反应是枚举四个终态,只要不在终态就告警。我们用的是逆向判定:只在 status == pending 时告警。
因为pending的下一个状态就是shipped,而发货完毕后的状态就是shipped。如果发货数为空,过10分钟后,状态还是pending,那肯定是供应商真的没有填写发货数了。
三、技术方案选型
虽然我最终选用的是数据库表来实现,但是当时我也考虑过其他方案的,如下。
Redis 延迟队列
用 Redisson 的 RDelayedQueue,写入时投递延迟消息,到期取出复核。
- 优点:实现直接,延迟精度可以到秒级;
- 缺点:消息可靠性依赖 Redis 持久化配置,重启或主从切换存在丢失窗口;复核状态对外不可见,想看「现在有多少单待复核」只能翻日志;丢一条消息就是漏一次告警,要补对账逻辑才能闭环。
MQ 延迟消息
RocketMQ 的延迟级别,或 RabbitMQ 的 TTL + 死信队列,到期消费后执行复核。
- 优点:可靠性高于 Redis 方案,延迟语义也是标准能力;
- 缺点:需要引入新组件依赖(我们项目当时没有延迟消息的现成设施);延迟消息发出后不可修改,如果需要撤销某单的复核,做不了;排查问题要切到 MQ 控制台看消息轨迹;消费端还要处理重复投递的幂等。
数据库表 + 定时任务
即最终落地的方案。
- 优点:零新增依赖;记录可查可审计,这张表本身就是一份告警台账,误报率、空推规律随时能统计;唯一键去重 + 条件更新把幂等和并发问题在数据库层面一次解决;轮询间隔带来的分钟级抖动,对分钟级时延要求的告警场景没有实际影响;
- 缺点:告警时延受轮询间隔影响;多维护一张表和一个定时任务。
3.4 选择数据库的原因
最终选数据库,原因说直白一点:不想为了一个告警复核场景引入新的中间件和新的复杂度。
这个场景的延迟要求是分钟级,不是秒级;事件频率是个位数/天;可靠性要求是「不能漏报」。这三条放在一起,一张表加一个定时任务正好覆盖。Redis 和 MQ 都不是不能用------如果项目里已经有成熟的延迟消息设施,选 MQ 也很自然------只是我们的场景收益撑不起新增依赖的成本。
四、实现方案
分层落地
按项目的 DDD 分层,功能跨四层:
| 层 | 产物 |
|---|---|
| common | DeliveryDocsShipmentWarnLogStatusEnum(waiting/passed/alarmed)、配置项 shipmentWarnLogCheckDelayMinutes(默认 10) |
| domain | DeliveryDocsShipmentWarnLogEntity(充血实体)、DeliveryDocsShipmentWarnLogRepository、复核条件 DTO |
| infrastructure | PO / Mapper / Converter / RepositoryImpl |
| application | 检测点改造、checkShipmentWarnLogs、JobProcessor |
有一个边界问题:告警日志要不要挂到配货单聚合里?评估后做成了独立轻量聚合:它和配货单主流程没有跨聚合不变量,不随主流程加载,生命周期也独立(单据闭环后日志还要留档)。所以单独建仓储,实体放在 delivery 包下,但只通过自己的仓储访问。
检测点改造
改造前,检测即告警:
Java
if (StringUtils.isBlank(detail.getShipmentNum())) {
StructuredLog.error(log)
.message("delivery_docs_shipment_num_is_empty")
.put("rowId", detail.getRowId())
.log();
return false;
}
改造后,降级为记录,延迟复核:
Java
if (StringUtils.isBlank(detail.getShipmentNum())) {
recordEmptyShipmentNumWarnLog(deliveryDocsNo, detail.getRowId());
return false;
}
recordEmptyShipmentNumWarnLog 做三件事:
Java
private void recordEmptyShipmentNumWarnLog(String deliveryDocsNo, String rowId) {
// 1. WARN 日志留痕。message 换成新值,刻意不包含旧告警字符串,
// 避免监控平台按 message 模糊匹配时把这条降级日志又当成告警
StructuredLog.warn(log)
.moduleCode(MODULE_CODE_DELIVERY_DOCS_SHIPMENT)
.moduleName(MODULE_NAME_DELIVERY_DOCS_SHIPMENT)
.docsNo(deliveryDocsNo)
.message("delivery_docs_shipment_warn_log_recorded")
.put("rowId", rowId)
.log();
try {
Integer delayMinutesConfig = deliveryDocsProperties.getShipmentWarnLogCheckDelayMinutes();
int delayMinutes = delayMinutesConfig == null ? DEFAULT_SHIPMENT_WARN_CHECK_DELAY_MINUTES : delayMinutesConfig;
DeliveryDocsShipmentWarnLogEntity warnLogEntity =
DeliveryDocsShipmentWarnLogEntity.create(deliveryDocsNo, LocalDateTime.now().plusMinutes(delayMinutes));
// 2. 写表,唯一键冲突直接忽略(重复空推)
deliveryDocsShipmentWarnLogRepository.saveIfAbsent(warnLogEntity);
} catch (Exception e) {
// 3. 兜底:写表失败立即按原 message 告警,退回旧行为,不能因为方案替换引入漏报
StructuredLog.error(log)
.message("delivery_docs_shipment_num_is_empty")
.put("rowId", rowId)
.exception(e)
.log();
}
}
兜底这一步是整个替换方案的安全垫:新方案的告警比旧方案多一个复核周期,唯一的漏报风险就出在写表这一步,所以写表失败时直接退回旧的即时告警。
复核与判定
定时任务入口按「先读后写」组织,批量查询避免循环内 N+1:
Java
public void checkShipmentWarnLogs(DeliveryDocsShipmentWarnCheckConditionDTO conditionDTO) {
List<DeliveryDocsShipmentWarnLogEntity> waitingLogList =
deliveryDocsShipmentWarnLogRepository.selectWaitingLogs(conditionDTO);
if (CollectionUtil.isEmpty(waitingLogList)) {
return;
}
List<String> docsNoList = waitingLogList.stream()
.map(DeliveryDocsShipmentWarnLogEntity::getDeliveryDocsNo).distinct().toList();
Map<String, String> docsNoStatusMap = deliveryDocsRepository
.getDeliveryDocsEntityByDocsNos(docsNoList).stream()
.filter(e -> e.getStatus() != null)
.collect(Collectors.toMap(DeliveryDocsEntity::getDocsNo,
DeliveryDocsEntity::getStatus, (k1, k2) -> k1));
for (DeliveryDocsShipmentWarnLogEntity warnLogEntity : waitingLogList) {
try {
judgeShipmentWarnLog(warnLogEntity, docsNoStatusMap);
} catch (Exception e) {
// 单条复核失败不影响其余记录
StructuredLog.error(log)
.moduleCode(MODULE_CODE_DELIVERY_DOCS_SHIPMENT)
.moduleName(MODULE_NAME_DELIVERY_DOCS_SHIPMENT)
.docsNo(warnLogEntity.getDeliveryDocsNo())
.message("复核配货单发货数告警日志失败")
.exception(e)
.log();
}
}
}
单条判定是逆向判定的落地:
Java
private void judgeShipmentWarnLog(DeliveryDocsShipmentWarnLogEntity warnLogEntity,
Map<String, String> docsNoStatusMap) {
String deliveryDocsStatus = docsNoStatusMap.get(warnLogEntity.getDeliveryDocsNo());
// 逆向判定:只在配货单仍停留在待发货状态时才告警
boolean stillPending = DeliveryDocsStatusEnum.PENDING.getCode().equals(deliveryDocsStatus);
if (stillPending) {
warnLogEntity.markAlarmed();
} else {
warnLogEntity.markPassed();
}
// 条件更新:只有记录还是 waiting 时才会更新成功,保证每单最多告警一次
boolean updated = deliveryDocsShipmentWarnLogRepository
.updateStatusIfWaiting(warnLogEntity.getId(), warnLogEntity.getWarnStatus());
if (updated && stillPending) {
StructuredLog.error(log)
.moduleCode(MODULE_CODE_DELIVERY_DOCS_SHIPMENT)
.moduleName(MODULE_NAME_DELIVERY_DOCS_SHIPMENT)
.docsNo(warnLogEntity.getDeliveryDocsNo())
.message("delivery_docs_shipment_num_is_empty") // 保留原 message,监控规则不用改
.put("warnLogId", warnLogEntity.getId())
.log();
}
}
条件更新解决并发问题:SQL 带 where id = ? and warn_status = 'waiting' and is_deleted = 0,两个任务实例同时复核同一条记录时,只有一个能更新成功,也只有它有资格打告警日志。
仓储层三个方法各管一件事:
saveIfAbsent:insert,捕获DuplicateKeyException忽略;selectWaitingLogs:warn_status = 'waiting' and check_time <= now,附加 45 天创建时间窗口防止历史数据累积后全表扫描,limit 500兜底单次批量;docsNos 参数不为空时走手工复核模式,按单据号查询、不受复核时间限制;updateStatusIfWaiting:条件更新,同时把 update_by 置为 system,返回是否更新成功。
告警语义迁移
| 环节 | 级别 | message | 说明 |
|---|---|---|---|
| 检测点(改造后) | WARN | delivery_docs_shipment_warn_log_recorded | 降级留痕,刻意不含旧告警串 |
| 写表失败兜底 | ERROR | delivery_docs_shipment_num_is_empty | 退回旧的即时告警 |
| 复核确认真问题 | ERROR | delivery_docs_shipment_num_is_empty | 与旧告警完全一致 |
这样设计有一个直接收益:监控平台的告警规则一行不用改。原来匹配 delivery_docs_shipment_num_is_empty 的规则,现在只会被「写表失败兜底」和「复核确认真问题」触发,误报在这条规则的上游就被挡掉了。
充血实体
状态流转收敛在实体内部,而不是散落在应用层的 if 里:
Java
public static DeliveryDocsShipmentWarnLogEntity create(String deliveryDocsNo, LocalDateTime checkTime) {
return DeliveryDocsShipmentWarnLogEntity.builder()
.deliveryDocsNo(deliveryDocsNo)
.warnStatus(DeliveryDocsShipmentWarnLogStatusEnum.WAITING.getCode())
.checkTime(checkTime)
.build();
}
public void markPassed() { transitionTo(DeliveryDocsShipmentWarnLogStatusEnum.PASSED); }
public void markAlarmed() { transitionTo(DeliveryDocsShipmentWarnLogStatusEnum.ALARMED); }
private void transitionTo(DeliveryDocsShipmentWarnLogStatusEnum targetStatus) {
if (!DeliveryDocsShipmentWarnLogStatusEnum.WAITING.getCode().equals(this.warnStatus)) {
throw BusinessException.of(ErrorEnum.PARAM_INVALID_VALUE.getCode(),
"配货单发货数告警日志不是待检查状态,不允许流转:" + this.warnStatus);
}
this.warnStatus = targetStatus.getCode();
}
「非 waiting 状态不允许流转」这条规则由实体自己守护。
定时任务
SchedulerX 任务,JSON 参数化:参数为空执行全量复核(正式运行模式),{"docsNos":"PH202610120001,PH202610120002"} 按单据号手工复核,用于运维排查,不受复核时间限制。
执行间隔配置为分钟级(建议 5 分钟)。间隔只影响告警时效,不影响正确性------到期判断以 check_time 为准,某次执行错过了,下一次会自动补上。以 5 分钟间隔为例,真问题的端到端告警时延 = 10 分钟复核延迟 + 05 分钟轮询抖动,即 1015 分钟,对这种核查类告警完全够用。
上线验证
上线次日就有两单真实空值推送走完完整链路,复核判定正常,监控平台没有再出现这类误报。日常观察用两条 SQL 就够:
SQL
-- 最近的空值推送及复核去向
SELECT delivery_docs_no, warn_status, check_time, update_time
FROM delivery_docs_shipment_warn_log ORDER BY id DESC LIMIT 20;
-- 真告警清单(需要人工跟进的单据)
SELECT delivery_docs_no, update_time FROM delivery_docs_shipment_warn_log
WHERE warn_status = 'alarmed' ORDER BY id DESC;
预期形态是绝大多数记录落在 passed,alarmed 才是真正需要看的单。
五、小结
这次治理没有用到复杂的技术,核心就两个动作:
- 把告警的判定时点从「请求到达时」挪到「延迟十分钟后的终态复核」;
- 把判定条件从「枚举四个正常终态」简化为「只认 pending」。
方案选择上,Redis 延迟队列和 MQ 延迟消息都是合理选项,最终选数据库表,是因为这个场景对延迟精度的要求(分钟级)、事件频率(个位数/天)、可靠性要求(不能漏报),一张表加一个定时任务正好覆盖,不需要为此引入新依赖。方案本身也没有什么巧妙之处,它只是把「告警应该基于业务终态而不是请求中间态」这个朴素的原则执行了一遍。
告警的价值在于可信。误报控制住之后,delivery_docs_shipment_num_is_empty 这条规则重新变成了真实告警,必须去处理了。