配货单发货数为空告警误报治理:写表延迟复核方案

记录一次线上告警误报的治理过程:从定位误报根因,到对比 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 发货数有值 幂等拦截

这个案例就是一次典型的重复触发:空值的第一次推送直接触发了告警,几秒后第二次有值推送补齐了发货数,单据本身是正常的,但告警已经发出去了。

且我明明在低代码平台上,把发货数设置为必填了,但是供应商批量操作发货的时候,就是会发生发货数不填的情况。醉了。

另外:

  1. 空值推送本身零业务影响。明细校验失败后直接 return,不写任何业务数据,单据状态不受影响;
  2. 最终结果通常是正常的。几秒后第二次有值推送会补齐发货数,单据正常流转。

也就是说,告警描述的问题绝大多数不存在,但每次告警值班同学都要去查一遍单据,确认是虚惊一场。误报多了,告警的可信度就被消耗掉了,真出问题时反而容易被忽略。

根因

告警绑定在「单次请求」维度,捕获的是瞬时中间态。而业务上真正关心的是「这单最终有没有把发货数补上」,这是一个终态问题。拿中间态去触发终态级别的告警,误报是必然的。

二、设计方案

核心思路

把「检测时立即告警」改成「检测时记录,延迟复核后再告警」:

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 才是真正需要看的单。

五、小结

这次治理没有用到复杂的技术,核心就两个动作:

  1. 把告警的判定时点从「请求到达时」挪到「延迟十分钟后的终态复核」;
  2. 把判定条件从「枚举四个正常终态」简化为「只认 pending」。

方案选择上,Redis 延迟队列和 MQ 延迟消息都是合理选项,最终选数据库表,是因为这个场景对延迟精度的要求(分钟级)、事件频率(个位数/天)、可靠性要求(不能漏报),一张表加一个定时任务正好覆盖,不需要为此引入新依赖。方案本身也没有什么巧妙之处,它只是把「告警应该基于业务终态而不是请求中间态」这个朴素的原则执行了一遍。

告警的价值在于可信。误报控制住之后,delivery_docs_shipment_num_is_empty 这条规则重新变成了真实告警,必须去处理了。

相关推荐
某些鱼1 小时前
系列4 目标应用架构怎么画,以及一份真实格式的 ADR:为什么 OMS 先于 ERP
架构
知守观1 小时前
mirrorOf= 把私服请求拦去了公网镜像:本地能跑,CI 上内部包 404 排查
后端·ci/cd·maven
一心同学1 小时前
Hermes架构拆解之Tool执行安全机制
安全·架构·agent·hermes
某些鱼1 小时前
系列6 架构治理:让系统别白建,让决策别白做
架构
某些鱼1 小时前
系列5 36 个月路线图:不是项目排期表,是决策的时间化表达
架构
万物智能1 小时前
鸿蒙UART总线怎么用?—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
后端
\光辉岁月/1 小时前
16.JavaSE-反射
java·反射
青衫烟雨1 小时前
DeepSeek 崩了 5 小时:你的 AI 应用为什么跟着一起挂?
人工智能·架构
范什么特西1 小时前
加深动态代理的理解
java