前面十四篇,我们一路从架构全景、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 内置了 SERVER、INSTANCE、NET、DATABASE 等类型,而业务自定义告警统一使用 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 = false 的 Result。这是一个关键的设计决策:告警发送失败不应该导致业务流程中断。 监控是“旁路系统”,它的故障不能传染给主业务。
四、异步发送: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。回顾几个关键要点:
-
Alarm领域模型:通过 Builder 模式构建,包含告警级别、监控类型、标题、内容、编码等字段。code字段支持数据库动态覆盖,实现告警文案的运行时管理;charset字段兼容非 UTF-8 环境。 -
sendAlarm()同步发送:走 HTTP 请求-响应模型,经过ClientPackageConstructor构造数据包 →Sender.send()加密发送 → 等待响应 → 返回Result。失败时不抛异常,返回isSuccess = false,保证不影响业务主流程。 -
asyncSendAlarm()异步发送:走 WebSocket 长连接,经过DataExchanger.isReady()前置检查 → 构造WebSocketPackage封装 →DataExchanger.sendMessage()推送。无返回值,不阻塞调用线程,连接不可用时抛异常。 -
buryingPoint()定时埋点:将业务检查逻辑封装为Runnable,交由BusinessBuryingPointTaskScheduler放入定时线程池周期性执行,适合“定时巡检 + 条件告警”的场景。 -
设计哲学——“旁路不侵入”:同步发送在遇到网络异常时不会向上抛出,而是返回
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

评论