目录

    Phoenix监控平台技术解析(十五):业务埋点——同步与异步告警发送 API 设计

    前面十四篇,我们一路从架构全景、HTTP/WebSocket 通信通道、加解密体系、配置加载、心跳机制、JVM 采集、线程池监控,一直拆到了 Spring Boot Starter 和 SpringMVC Integrator 两条集成路径。这些都属于 Phoenix 客户端的“基础设施层”——它们默默运转,不需要业务开发者操心。但监控平台的真正价值,往往在于那些需要开发者“主动出手”的时刻——比如,在业务代码中发出一条告警。


    一、为什么需要业务告警 API?

    Phoenix 内置的监控能力(心跳、JVM、服务器、网络设备)都是“自动驾驶”模式——Monitor.start() 一调用,后台线程就开始周期性采集、上报,业务代码完全无感。

    但有些场景,自动采集覆盖不到:

    • 订单支付失败率突然飙升,需要立即通知运维
    • 某个第三方接口响应超时,想触发一条警告
    • 定时对账任务发现数据不一致,需要发一条严重告警
    • 关键业务流程的异常分支,想要实时感知

    这些都是业务层面的监控需求——只有业务代码自己知道“什么时候该喊一嗓子”。Phoenix 为此提供了两个核心方法:Monitor.sendAlarm()(同步)和 Monitor.asyncSendAlarm()(异步)。它们是 Phoenix 客户端暴露给业务开发者的“紧急按钮”。


    二、Alarm:告警信息的领域模型

    在看发送逻辑之前,先认识一下告警信息的载体——Alarm 类。

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @Accessors(chain = true)
    @EqualsAndHashCode(callSuper = true)
    public final class Alarm extends AbstractSuperBean {
    
        /**
         * 告警级别,默认为:WARN(警告)。
         * 注意:如果是自定义业务告警(MonitorTypeEnums.CUSTOM),并且设置了告警编码,
         * 会根据告警编码查询数据库中对应的告警级别,数据库中对应的告警级别 会 覆盖直接设置的告警级别。
         */
        @Builder.Default
        private AlarmLevelEnums alarmLevel = AlarmLevelEnums.WARN;
    
        /**
         * 告警原因,默认为:IGNORE(忽略)。
         */
        @Builder.Default
        private AlarmReasonEnums alarmReason = AlarmReasonEnums.IGNORE;
    
        /**
         * 监控类型,默认为:CUSTOM(自定义)。
         */
        @Builder.Default
        private MonitorTypeEnums monitorType = MonitorTypeEnums.CUSTOM;
    
        /**
         * 监控子类型,默认为:EMPTY(空)。
         */
        @Builder.Default
        private MonitorSubTypeEnums monitorSubType = MonitorSubTypeEnums.EMPTY;
    
        /**
         * 字符集,如果当前字符集不是UTF-8,请指明字符集。
         */
        private Charset charset;
    
        /**
         * 是否是测试告警,测试告警服务端不发送告警消息。
         */
        private boolean isTest;
    
        /**
         * 告警标题。
         * 注意:如果是自定义业务告警(MonitorTypeEnums.CUSTOM),并且设置了告警编码,
         * 会根据告警编码查询数据库中对应的告警标题,数据库中对应的告警标题 会 覆盖直接设置的告警标题。
         */
        private String title;
    
        /**
         * 告警内容。
         * 注意:如果是自定义业务告警(MonitorTypeEnums.CUSTOM),并且设置了告警编码,
         * 会根据告警编码查询数据库中对应的告警内容,数据库中对应的告警内容 会 覆盖直接设置的告警内容。
         */
        private String msg;
    
        /**
         * 告警编码。
         * 针对每一个自定义的业务告警,都可以设置一个唯一的编码,用此编码来查找数据库中对应的告警级别、告警标题、告警内容,
         * 如果不设置编码,将直接使用设置的告警级别、告警标题、告警内容。
         * 注意:如果是自定义业务告警(MonitorTypeEnums.CUSTOM),编码在数据库中对应的告警级别、告警标题、告警内容
         * 会 覆盖直接设置的告警级别、告警标题、告警内容。
         */
        private String code;
    
        /**
         * 被告警主体唯一ID。
         */
        private String alertedEntityId;
    
        /**
         * 是否无视静默告警,如果无视,则即使在静默告警时间段,也会发送告警,默认不无视。
         */
        private boolean isIgnoreSilence;
    }
    

    字段不多,但每一个都有讲究。

    2.1 告警级别:AlarmLevelEnums

    Phoenix 定义了五个告警级别,从低到高排列:

    级别 含义 行为
    IGNORE 忽略 仅保存告警记录,不发送告警消息
    INFO 消息 发送告警消息,通知级别
    WARN 警告 发送告警消息,需要关注
    ERROR 错误 发送告警消息,需要处理
    FATAL 严重 发送告警消息,需要立即处理

    默认级别是 WARN。注意 IGNORE 级别的特殊性——它会在服务端留下记录,但不会真正发送告警通知。这个设计很实用:有些告警你想“记一笔”以便事后追溯,但又不想半夜把运维吵醒。

    2.2 监控类型:MonitorTypeEnums.CUSTOM

    monitorType 字段标识这条告警属于哪个监控维度。Phoenix 内置了 SERVERINSTANCENETDATABASE 等类型,而业务自定义告警统一使用 CUSTOM 类型——这也是 Alarm 的默认值。

    2.3 告警编码:code 的“数据库覆盖”机制

    code 是一个容易被忽略但极其精妙的字段。它的作用是:为每一条自定义业务告警建立一个唯一标识,然后通过这个标识去数据库查找预配置的告警级别、标题和内容。

    看源码中的注释就很清楚了:

    如果是自定义业务告警(MonitorTypeEnums.CUSTOM),并且设置了告警编码,会根据告警编码查询数据库中对应的告警级别、告警标题、告警内容,数据库中对应的告警级别、告警标题、告警内容会覆盖直接设置的告警级别、告警标题、告警内容。

    这意味着什么?告警的内容可以不写死在代码里,而是通过数据库动态管理。 想改告警文案?改数据库就行,不用改代码、不用重新部署。这在大规模运维场景中非常有价值——比如你有几十条业务告警,某天老板说“告警文案里把‘系统异常’改成‘服务暂时不可用’”,只需要在管理后台改一下数据库配置,所有客户端的告警文案立刻生效。

    2.4 charset:字符集兼容

    charset 字段是为非 UTF-8 环境准备的。如果你的应用使用 GBK 等字符集,可以在 Alarm 中显式指定,Phoenix 会在构造 AlarmPackage 时自动将标题和内容转换为 UTF-8。后面分析 structureAlarmPackage() 时会看到具体实现。

    2.5 isIgnoreSilence:无视静默期

    有些告警实在太重要了——即使正处于静默时间段(比如凌晨2点到6点的“免打扰”期),也必须发出来。isIgnoreSilence = true 就是为这种场景设计的“打破沉默”按钮。


    三、同步发送:Monitor.sendAlarm()

    先看最直观的同步版本:

    public static Result sendAlarm(Alarm alarm) {
        try {
            // 构造告警数据包
            AlarmPackage alarmPackage = CLIENT_PACKAGE_CONSTRUCTOR.structureAlarmPackage(alarm);
            String result = Sender.send(UrlConstants.ALARM_URL, alarmPackage.toJsonString());
            BaseResponsePackage baseResponsePackage = JSON.parseObject(result, BaseResponsePackage.class);
            return baseResponsePackage.getResult();
        } catch (IOException | NetException e) {
            log.error("监控程序发送告警信息异常!", e);
            return Result.builder().isSuccess(false).msg(e.getMessage()).build();
        }
    }
    

    短短几行,干了三件事。

    3.1 第一步:构造告警数据包

    AlarmPackage alarmPackage = CLIENT_PACKAGE_CONSTRUCTOR.structureAlarmPackage(alarm);
    

    ClientPackageConstructor 是我们在前面几篇中多次打照面的“包工头”——所有发往服务端的数据包(心跳包、JVM 包、服务器包、告警包)都由它统一构造。看看它为告警包做了什么:

    @Override
    public AlarmPackage structureAlarmPackage(Alarm alarm) throws NetException {
        AlarmPackage alarmPackage = new AlarmPackage();
        // 构造基础请求包数据
        this.structureBaseRequestPackage(alarmPackage, null);
        // 判断字符集
        Charset charset = alarm.getCharset();
        // 设置了字符集
        if (null != charset) {
            alarm.setTitle(new String(alarm.getTitle().getBytes(charset), StandardCharsets.UTF_8));
            alarm.setMsg(new String(alarm.getMsg().getBytes(charset), StandardCharsets.UTF_8));
            alarm.setCharset(StandardCharsets.UTF_8);
        }
        alarmPackage.setAlarm(alarm);
        return alarmPackage;
    }
    

    两个动作:

    1. 填充基础请求包字段。 structureBaseRequestPackage() 会设置请求 ID(UUID)、请求时间、应用实例端点、实例 ID、实例名、IP 地址、计算机名、链路信息等公共字段。这些字段对于所有类型的数据包都是相同的——心跳包有、JVM 包有、告警包也有。

    2. 字符集转换。 如果业务代码在 Alarm 中指定了 charset(比如 GBK),这里会把标题和内容从原始字符集转换为 UTF-8。转换完成后,charset 字段被覆写为 UTF-8,确保后续的序列化和传输统一使用 UTF-8 编码。

    3.2 第二步:HTTP 同步发送

    String result = Sender.send(UrlConstants.ALARM_URL, alarmPackage.toJsonString());
    

    Sender.send() 是 Phoenix 客户端 HTTP 通信的统一出口。回顾一下它的实现:

    public static String send(final String url, final String json) throws IOException {
        // 将 明文JSON字符串 转换成 密文JSON字符串
        String encryptStr = MsgPayloadUtils.encryptPayload(json);
        // 发送请求
        EnumPoolingHttpClient httpClient = EnumPoolingHttpClient.getInstance();
        String result = httpClient.sendHttpPostByJson(url, encryptStr);
        // 将 密文JSON字符串 转换成 明文JSON字符串
        String decryptStr = MsgPayloadUtils.decryptPayload(result);
        return decryptStr;
    }
    

    完整链路是:序列化 → 加密 → HTTP POST → 收到响应 → 解密 → 反序列化。 这正是第二篇(HTTP 通信通道)和第六篇(加解密体系)、第七篇(数据压缩)中讲过的那套流程——告警数据也不例外,同样要经过加密保护。

    告警的目标 URL 是 UrlConstants.ALARM_URL,即 {服务端地址}/alarm/accept-alarm-package

    3.3 第三步:解析返回结果

    BaseResponsePackage baseResponsePackage = JSON.parseObject(result, BaseResponsePackage.class);
    return baseResponsePackage.getResult();
    

    服务端处理完告警后,返回一个 BaseResponsePackage,其中包含一个 Result 对象:

    public final class Result extends AbstractSuperBean {
        private boolean isSuccess;
        private String msg;
    }
    

    业务代码拿到 Result,就能知道这条告警是否被服务端成功接收和处理。

    3.4 异常兜底

    catch (IOException | NetException e) {
        log.error("监控程序发送告警信息异常!", e);
        return Result.builder().isSuccess(false).msg(e.getMessage()).build();
    }
    

    如果发送过程中出现网络异常或 IO 异常,不会抛出去影响业务代码——而是返回一个 isSuccess = falseResult这是一个关键的设计决策:告警发送失败不应该导致业务流程中断。 监控是“旁路系统”,它的故障不能传染给主业务。


    四、异步发送:Monitor.asyncSendAlarm()

    异步版本走的是完全不同的通道:

    public static void asyncSendAlarm(Alarm alarm) {
        if (!DataExchanger.isReady()) {
            throw new MonitoringUniversalException("数据交换器未准备好,请稍后再试!");
        }
        try {
            // 构造告警数据包
            AlarmPackage alarmPackage = CLIENT_PACKAGE_CONSTRUCTOR.structureAlarmPackage(alarm);
            // 发送请求
            WebSocketPackage requestPackage = new WebSocketPackage();
            requestPackage.setClassName(AlarmPackage.class.getName());
            requestPackage.setPayload(alarmPackage);
            DataExchanger.sendMessage(requestPackage);
        } catch (NetException e) {
            log.error("监控程序异步发送告警信息异常!", e);
        }
    }
    

    4.1 前置检查:DataExchanger.isReady()

    public static boolean isReady() {
        WebsocketClient client = wsClient;
        if (client == null) {
            return false;
        }
        return client.isConnected();
    }
    

    异步发送依赖 WebSocket 长连接。如果 WebSocket 客户端还没初始化、或者连接已断开(正在重连中),isReady() 返回 false,此时直接抛出 MonitoringUniversalException

    注意这里和同步版本的一个重要区别——异步版本在连接不可用时抛异常,而不是静默失败。 为什么?因为异步方法没有返回值(void),无法通过 Result 告诉调用方“发送失败了”。抛异常是唯一的失败通知途径。

    4.2 WebSocketPackage 封装

    WebSocketPackage requestPackage = new WebSocketPackage();
    requestPackage.setClassName(AlarmPackage.class.getName());
    requestPackage.setPayload(alarmPackage);
    

    WebSocket 通道是一条“多路复用”的管道——心跳包、服务器信息包、JVM 包、告警包都走同一条连接。那服务端收到消息后怎么知道这是一个告警包?靠的就是 className 字段——它存储了负载数据的类全限定名(com.gitee.pifeng.monitoring.common.dto.AlarmPackage),服务端据此进行反序列化和路由分发。

    这个设计在第三篇(WebSocket 通信通道)中详细讲过——WebSocketPackage 就像一个“信封”,className 是信封上的收件地址,payload 是信封里的内容。

    4.3 DataExchanger.sendMessage()

    public static void sendMessage(WebSocketPackage requestPackage) {
        String requestPackageJsonStr = requestPackage.toJsonString();
        // 将 明文JSON字符串 转换成 密文JSON字符串
        String encryptStr = MsgPayloadUtils.encryptPayload(requestPackageJsonStr);
        WebsocketClient client = wsClient;
        if (client == null) {
            log.warn("WebSocket客户端尚未初始化,消息已丢弃!");
            return;
        }
        client.sendMessage(encryptStr);
    }
    

    消息经过 JSON 序列化和加密后,通过 WebSocket 长连接直接推送到服务端。没有等待响应,没有返回值——这就是“异步”的含义。 消息发出去就完事了,至于服务端处理得怎么样、告警有没有成功入库,调用方不会知道,也不需要等。


    五、同步 vs 异步:如何选择?

    把两种方式放在一起对比:

    对比维度 sendAlarm()(同步) asyncSendAlarm()(异步)
    通信通道 HTTP 请求-响应 WebSocket 长连接
    返回值 Result(含成功/失败状态和消息) void(无返回值)
    是否阻塞 阻塞,等待服务端响应 不阻塞,发送即返回
    失败处理 返回 isSuccess = false 连接不可用时抛异常;发送异常时记日志
    连接依赖 HTTP 连接池(始终可用) WebSocket 连接(需 isReady()
    适用场景 需要确认告警是否成功到达的关键场景 高频告警、性能敏感、不关心发送结果的场景

    简单的选择建议:

    • 如果你需要知道告警是否成功发送(比如根据发送结果决定是否重试),用 sendAlarm()
    • 如果你追求更低的调用开销、不想阻塞业务线程,用 asyncSendAlarm()
    • 如果拿不准,用 sendAlarm()——同步版本更可靠,语义更清晰

    六、定时埋点:Monitor.buryingPoint()

    除了“手动触发”的告警发送,Phoenix 还提供了一种“定时巡检”式的业务埋点能力:

    public static MonitoredScheduledThreadPoolExecutor buryingPoint(
            Runnable command, long initialDelay, long period, 
            TimeUnit unit, ThreadTypeEnums threadTypeEnum) {
        return BusinessBuryingPointTaskScheduler.run(
                command, initialDelay, period, unit, threadTypeEnum);
    }
    

    buryingPoint() 的思路是:你提供一个 Runnable 任务(里面包含你的业务检查逻辑和告警发送逻辑),Phoenix 帮你放进定时线程池里周期性执行。

    背后的 BusinessBuryingPointTaskScheduler 实现很简洁:

    public static MonitoredScheduledThreadPoolExecutor run(
            Runnable command, long initialDelay, long period, 
            TimeUnit unit, ThreadTypeEnums threadTypeEnum) {
        // CPU密集型
        if (threadTypeEnum == ThreadTypeEnums.CPU_INTENSIVE_THREAD) {
            final MonitoredScheduledThreadPoolExecutor executor = 
                    ThreadPool.getCommonCpuIntensiveScheduledThreadPoolExecutor();
            executor.scheduleAtFixedRate(command, initialDelay, period, unit);
            return executor;
        }
        // IO密集型
        else {
            final MonitoredScheduledThreadPoolExecutor executor = 
                    ThreadPool.getCommonIoIntensiveScheduledThreadPoolExecutor();
            executor.scheduleAtFixedRate(command, initialDelay, period, unit);
            return executor;
        }
    }
    

    根据任务的类型(CPU 密集型 or IO 密集型),选择不同的线程池执行。这两个线程池正是第十二篇中讲过的 MonitoredScheduledThreadPoolExecutor——它们自身也被纳入了线程池监控,形成了一种“监控自身也被监控”的递归式设计。

    一个典型的使用场景:

    Monitor.buryingPoint(() -> {
        // 检查订单超时未支付的数量
        long count = orderService.countUnpaidOverdue();
        if (count > 100) {
            Alarm alarm = Alarm.builder()
                    .alarmLevel(AlarmLevelEnums.WARN)
                    .title("订单超时未支付数量异常")
                    .msg("当前超时未支付订单数量:" + count + ",超过阈值100")
                    .build();
            Monitor.sendAlarm(alarm);
        }
    }, 0, 5, TimeUnit.MINUTES, ThreadTypeEnums.IO_INTENSIVE_THREAD);
    

    每 5 分钟查一次数据库,发现异常就发告警——整个逻辑只需要几行代码,线程池管理、定时调度全由 Phoenix 托管。


    七、使用示例

    最后看看 Phoenix 项目自带的测试用例,它们也是最好的使用范例。

    7.1 同步发送告警

    @Test
    public void testSendAlarm() {
        // 开启监控
        Monitor.start();
        // 封装告警信息
        Alarm alarm = Alarm.builder()
                .alarmLevel(AlarmLevelEnums.INFO)
                .monitorType(MonitorTypeEnums.CUSTOM)
                .title("测试发送告警信息")
                .msg("测试发送告警信息")
                .charset(Charsets.UTF_8)
                .isTest(false)
                .build();
        // 发送告警信息
        Result result = Monitor.sendAlarm(alarm);
        Console.log(result.toJsonString());
    }
    

    7.2 异步发送告警

    @Test
    public void testAsyncSendAlarm() {
        // 开启监控
        Monitor.start();
        // 封装告警信息
        Alarm alarm = Alarm.builder()
                .alarmLevel(AlarmLevelEnums.INFO)
                .monitorType(MonitorTypeEnums.CUSTOM)
                .title("测试异步发送告警信息")
                .msg("测试发异步送告警信息")
                .charset(Charsets.UTF_8)
                .isTest(false)
                .build();
        // 异步发送告警信息
        Monitor.asyncSendAlarm(alarm);
    }
    

    两段代码几乎一样,区别只在最后一步:同步版本拿到 Result 并打印,异步版本调用完就结束了。在实际业务代码中,Monitor.start() 通常在应用启动时就已经执行过了(由 Starter 或 Integrator 触发),业务代码只需要关注 Alarm 的构建和 sendAlarm() / asyncSendAlarm() 的调用。


    八、小结

    这一篇我们拆解了 Phoenix 客户端面向业务开发者的告警发送 API。回顾几个关键要点:

    1. Alarm 领域模型:通过 Builder 模式构建,包含告警级别、监控类型、标题、内容、编码等字段。code 字段支持数据库动态覆盖,实现告警文案的运行时管理;charset 字段兼容非 UTF-8 环境。

    2. sendAlarm() 同步发送:走 HTTP 请求-响应模型,经过 ClientPackageConstructor 构造数据包 → Sender.send() 加密发送 → 等待响应 → 返回 Result。失败时不抛异常,返回 isSuccess = false,保证不影响业务主流程。

    3. asyncSendAlarm() 异步发送:走 WebSocket 长连接,经过 DataExchanger.isReady() 前置检查 → 构造 WebSocketPackage 封装 → DataExchanger.sendMessage() 推送。无返回值,不阻塞调用线程,连接不可用时抛异常。

    4. buryingPoint() 定时埋点:将业务检查逻辑封装为 Runnable,交由 BusinessBuryingPointTaskScheduler 放入定时线程池周期性执行,适合“定时巡检 + 条件告警”的场景。

    5. 设计哲学——“旁路不侵入”:同步发送在遇到网络异常时不会向上抛出,而是返回 isSuccess = false,保证不干扰业务主流程;异步发送在数据交换器未就绪时会抛出 MonitoringUniversalException(因为没有返回值,这是唯一的失败通知途径),发送过程中的网络异常则仅记录日志。整体设计思路是:监控作为旁路系统,尽可能“尽力而为”地传递信息,而不是成为业务流程的瓶颈或风险点。

    从心跳、采集、通信,到配置加载、集成适配,再到今天的业务告警 API——Phoenix 客户端 SDK 的核心能力链条已经完整串联。至此,客户端篇章正式收官。下一篇起,我们将进入 Phoenix 的第三大模块——代理端(Agent),看看它如何在客户端与服务端之间扮演“中转枢纽”的角色。敬请期待。


    项目地址
    https://gitcode.com/monitoring-platform/phoenix
    https://gitee.com/monitoring-platform/phoenix
    https://github.com/709343767/phoenix

    欢迎关注微信公众号获取更多技术干货
    微信公众号·披锋斩棘

    end
  1. 作者: 锋哥 (联系作者)
  2. 发表时间: 2026-04-09 10:40
  3. 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)
  4. 转载声明:如果是转载博主转载的文章,请附上原文链接
  5. 公众号转载:请在文末添加作者公众号二维码(公众号二维码见右边,欢迎关注)
  6. 评论

    站长头像 知录

    你一句春不晚,我就到了真江南!

    文章0
    浏览0

    文章分类

    标签云