Spring Boot实现值班强确认通知系统:从告警接入到自动升级闭环
在很多团队的值班群里真正让负责人紧张的并不是告警本身而是那句催办的话“指挥官快接电话。”这句话翻译成技术问题就是监控平台已经把 P1 告警推送出来了IM 机器人也发了邮件也抄送了但当前值班人因为开会、静音或者正在处理另一个故障没有在第一时间确认。故障处理窗口从 10 分钟拖到 1 小时最后靠人工打电话才找到人。问题根子往往不是监控告警配置太少而是告警链路只有“发送”没有“确认”“升级”“留痕”这三个环节。这里用 Spring Boot 实现一个轻量的值班强确认通知系统。它不替代监控平台而是接收监控平台推送过来的告警事件自动找到当前值班人通过 webhook、邮件等渠道通知并强制要求值班人确认如果超时未确认系统会自动升级到第二联系人同时把每一轮通知、确认、升级都记录成可审计的日志。掌握这套逻辑后你可以把它落地到监控告警、发布平台、工单系统或任何“需要确保有人响应”的场景。1. 先定义清楚告警系统要解决的是“被接下”不是“被发出”1.1 普通通知与强确认通知的差别普通通知模型下发送方把消息发到 IM 群、邮箱或浏览器任务就结束了。它不关心接收方是否看到、是否理解、是否开始处理。这种模型适合信息同步不适合告警。强确认通知模型多了一个关键动作接收方必须在规定时间内主动确认。确认可以是点击链接、回复关键字也可以直接调用确认接口。如果超时没有确认系统自动进入升级流程。两者的本质差别是前者对“已发出”负责后者对“已响应”负责。能力普通通知强确认通知发送结果记录有有接收方确认动作无必须有超时自动升级无自动责任留痕弱强适用场景信息同步、日常推送P1/P2 告警、值班、发布审批1.2 告警通知必须解决的三个子问题第一事件去重。监控平台在故障期间可能连续触发 10 次相同告警如果每次都通知值班人会造成刷屏和通知疲劳反而把真正需要响应的告警淹没掉。第二值班人路由。系统要知道“当前这个时间段归谁管”。值班表会调整、会换人所以不能把负责人写死在配置里必须从值班排班表按日期和团队动态查询。第三升级与收敛。如果值班人长时间不确认系统要把通知升级到第二负责人、值班 leader 或全员群。但要防止无限制升级每一轮都必须有等待时间、最大轮数和渠道上限。1.3 系统能力边界这套系统不是监控平台不负责采集指标和判断阈值也不是完整的故障管理平台不负责复盘、值班日历审批和 SLA 统计。它只做一件事把已经产生的告警事件可靠地触达给正确的人并强制形成响应闭环。边界清楚的好处是它可以作为独立服务接入多个上游平台。Prometheus、Zabbix、发布平台、工单系统都可以通过 HTTP 接口把事件推进来后续扩展渠道和升级规则时不需要改动上游。2. 系统设计与技术选型先定链路再写代码2.1 一条完整的告警触达链路在写第一行代码前先把链路理清楚。本文实现的链路是监控平台产生告警调用本系统接入接口系统对事件号去重防止重复通知查询当天值班人通过渠道适配器发出第一轮通知值班人确认后流程结束超时未确认则进入升级扫描按升级策略发送下一轮通知每轮结果写入通知记录表。这条链路里有一个容易混淆的点通知发送成功和值班人确认成功是两回事。webhook 返回 200只代表消息送出去了不代表有人点击确认。所以系统里必须单独维护确认状态。2.2 技术选型与组件职责为了减少篇幅本文使用单体 Spring Boot 服务不引入微服务和复杂消息队列。示例代码目标是跑通闭环生产环境再按实际流量补组件。组件版本参考职责JDK17运行环境Spring Boot 3 要求 JDK 17 起Spring Boot3.2.xWeb 接口、定时任务、依赖注入MySQL8.x告警事件、值班表、通知记录、升级策略持久化Redis6.x 及以上告警去重、确认防重、短期状态缓存MyBatis-Plus3.5.x数据库访问需使用对应 Spring Boot 3 的 starterSpring Task随 Spring Boot超时升级扫描任务这里要说明一点所有版本号只是示例落地前先确认与现有基础环境的兼容性尤其是 MyBatis-Plus 的 starter 要和 Spring Boot 大版本对应。2.3 核心概念事件event_no上游告警的唯一标识由监控平台生成例如P1-20240612-001。它是去重、查询、确认的主键。值班周期duty按团队和日期组织的排班表每天有一个主值班人和若干备份人。轮次round同一事件的通知次数。第一轮通知主值班人第二轮升级到备份人后续轮次可以在策略里定义。渠道channel通知目标的抽象。webhook、邮件、短信、语音都算渠道。本文用 webhook 作为默认实现。升级策略escalation_policy按告警级别定义每轮等待时间、最大轮数、目标角色和渠道。2.4 核心表结构 DDL先建库和四张表。实际项目中表名和字段可以按团队规范调整但核心状态机字段不要省。CREATE DATABASE IF NOT EXISTS alert_ack DEFAULT CHARACTER SET utf8mb4; CREATE TABLE alert_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_no VARCHAR(64) NOT NULL COMMENT 告警事件号上游唯一, title VARCHAR(255) NOT NULL COMMENT 告警标题, content TEXT COMMENT 告警详情, level_code TINYINT NOT NULL DEFAULT 3 COMMENT 1P1,2P2,3P3, source_system VARCHAR(64) COMMENT 上游系统, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待确认,1已确认,2升级中,3已关闭, current_owner VARCHAR(64) COMMENT 当前负责人, escalation_round INT NOT NULL DEFAULT 1 COMMENT 当前通知轮次, ack_user VARCHAR(64) COMMENT 确认人, ack_time DATETIME COMMENT 确认时间, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_event_no (event_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT告警事件表; CREATE TABLE duty_rotation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, team_code VARCHAR(32) NOT NULL COMMENT 团队编码, user_id VARCHAR(64) NOT NULL COMMENT 人员 ID, user_name VARCHAR(32) COMMENT 姓名, notify_target VARCHAR(255) COMMENT 通知目标如 webhook 地址, work_date DATE NOT NULL COMMENT 值班日期, is_primary TINYINT NOT NULL DEFAULT 1 COMMENT 1主值班,0备份, UNIQUE KEY uk_team_date (team_code, work_date, is_primary) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT值班排班表; CREATE TABLE notification_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_no VARCHAR(64) NOT NULL, channel VARCHAR(32) NOT NULL COMMENT 渠道类型, target VARCHAR(255) COMMENT 通知目标, round INT NOT NULL DEFAULT 1 COMMENT 通知轮次, result TINYINT NOT NULL DEFAULT 0 COMMENT 0发送失败,1发送成功, fail_reason VARCHAR(512) COMMENT 失败原因, create_time DATETIME NOT NULL, KEY idx_event_no (event_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT通知记录表; CREATE TABLE escalation_policy ( id BIGINT PRIMARY KEY AUTO_INCREMENT, level_code TINYINT NOT NULL COMMENT 告警级别, wait_seconds INT NOT NULL DEFAULT 300 COMMENT 本轮超时等待秒数, max_round INT NOT NULL DEFAULT 3 COMMENT 最大升级轮数, target_role VARCHAR(32) NOT NULL DEFAULT PRIMARY COMMENT PRIMARY主值班,BACKUP备份, channel VARCHAR(32) NOT NULL DEFAULT webhook COMMENT 本轮使用的渠道, KEY idx_level (level_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT升级策略表;alert_event.status是整个系统的状态机核心。升级和确认都要在这个字段上做条件更新否则会出现“已确认但还在升级”的并发问题。3. 环境准备与项目骨架3.1 前置环境清单依赖检查方式提醒JDK 17java -version版本过低会导致 Spring Boot 3 无法启动MySQL 8mysql --version初始化 DDL 脚本Redisredis-cli ping返回 PONG 即可Mavenmvn -version3.6 以上一个可接收 webhook 的本地服务见 6.1用于验证通知是否真的发出学习环境可以全部跑在本地。生产环境建议把值班表、策略、通知目标和数据库连接都外置到配置中心或环境变量不要写死在代码里。3.2 Maven 依赖配置parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies注意MyBatis-Plus 3.5.5 只能作为参考版本。如果项目使用 Spring Boot 3.x 就选mybatis-plus-spring-boot3-starter使用 Spring Boot 2.x 时则要换回另一个 starter 并核对版本兼容性。3.3 核心配置文件application.yml里主要配置数据源、Redis、定时任务和 MyBatis-Plus 的驼峰映射。server: port: 8080 spring: application: name: alert-ack-service datasource: url: jdbc:mysql://localhost:3306/alert_ack?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver data: redis: host: localhost port: 6379 timeout: 3000ms task: scheduling: pool: size: 4 mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: automap-underscore-to-camel-case让event_no自动映射为eventNo避免手写大段 ResultMap。时区统一使用Asia/Shanghai否则跨天取值时可能出现值班人错乱。3.4 项目目录结构alert-ack-service/ ├── pom.xml └── src/main/ ├── java/com/example/ack/ │ ├── AlertAckApplication.java │ ├── controller/ │ │ ├── AlertController.java │ │ └── AckController.java │ ├── service/ │ │ ├── AlertService.java │ │ ├── DutyService.java │ │ ├── NotificationService.java │ │ └── EscalationScheduler.java │ ├── adapter/ │ │ ├── ChannelAdapter.java │ │ └── WebhookChannelAdapter.java │ ├── domain/ │ │ ├── AlertEvent.java │ │ ├── DutyRotation.java │ │ ├── NotificationRecord.java │ │ └── EscalationPolicy.java │ ├── dto/ │ │ ├── AlertIngestRequest.java │ │ └── Result.java │ └── mapper/ │ ├── AlertEventMapper.java │ ├── DutyRotationMapper.java │ ├── NotificationRecordMapper.java │ └── EscalationPolicyMapper.java └── resources/ └── application.yml控制器层只做参数接收和结果包装核心逻辑放到 Service。渠道适配器单独一个包是为了后续扩展短信、邮件、语音时不需要改动 AlertService。4. 核心代码实现从告警接入到第一轮通知4.1 告警事件实体实体类使用 MyBatis-Plus 注解映射。package com.example.ack.domain; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(alert_event) public class AlertEvent { TableId(type IdType.AUTO) private Long id; private String eventNo; private String title; private String content; private Integer levelCode; private String sourceSystem; private Integer status; private String currentOwner; private Integer escalationRound; private String ackUser; private LocalDateTime ackTime; private LocalDateTime createTime; private LocalDateTime updateTime; }status用Integer而不是String枚举是为了查询和条件更新更方便。状态含义在代码常量类里维护避免魔法数字散落各处。4.2 值班人查询值班查询按团队编码和日期优先取主值班人。package com.example.ack.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.ack.domain.DutyRotation; import com.example.ack.mapper.DutyRotationMapper; import org.springframework.stereotype.Service; import java.time.LocalDate; Service public class DutyService { private final DutyRotationMapper dutyRotationMapper; public DutyService(DutyRotationMapper dutyRotationMapper) { this.dutyRotationMapper dutyRotationMapper; } public DutyRotation findPrimary(String teamCode, LocalDate workDate) { return dutyRotationMapper.selectOne( new LambdaQueryWrapperDutyRotation() .eq(DutyRotation::getTeamCode, teamCode) .eq(DutyRotation::getWorkDate, workDate) .eq(DutyRotation::getIsPrimary, 1) .last(LIMIT 1)); } public DutyRotation findBackup(String teamCode, LocalDate workDate) { return dutyRotationMapper.selectOne( new LambdaQueryWrapperDutyRotation() .eq(DutyRotation::getTeamCode, teamCode) .eq(DutyRotation::getWorkDate, workDate) .eq(DutyRotation::getIsPrimary, 0) .last(LIMIT 1)); } }如果查询结果为空需要在上层做空判断。值班表没生成时宁可把告警记录成无人分派也不要抛异常打断上游接入。4.3 渠道适配器设计渠道适配器暴露统一接口接入新渠道时新增一个实现类即可。package com.example.ack.adapter; public interface ChannelAdapter { String channelName(); boolean send(String target, String title, String content, int round); }webhook 实现使用 RestTemplate 推送 JSON。这里的关键是设置超时时间外部渠道慢不能拖住告警接入接口。package com.example.ack.adapter; import org.springframework.http.*; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import java.util.HashMap; import java.util.Map; Component public class WebhookChannelAdapter implements ChannelAdapter { private final RestTemplate restTemplate; public WebhookChannelAdapter(RestTemplate restTemplate) { this.restTemplate restTemplate; } Override public String channelName() { return webhook; } Override public boolean send(String target, String title, String content, int round) { if (target null || target.isBlank()) { return false; } MapString, Object body new HashMap(); body.put(title, title); body.put(content, content); body.put(round, round); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntityMapString, Object request new HttpEntity(body, headers); try { ResponseEntityString response restTemplate.postForEntity(target, request, String.class); return response.getStatusCode().is2xxSuccessful(); } catch (Exception e) { return false; } } }send返回 boolean 只是最简单的结果判断。生产环境建议把响应体、异常堆栈都记录到通知记录表失败原因不能只写“发送失败”四个字。4.4 告警接入服务与去重告警接入的核心顺序是先去重再查值班人再落库最后发通知。先落库再发通知是为了避免通知发出去了但事件没存上导致确认时找不到记录。package com.example.ack.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.ack.domain.AlertEvent; import com.example.ack.domain.DutyRotation; import com.example.ack.dto.AlertIngestRequest; import com.example.ack.mapper.AlertEventMapper; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; Service public class AlertService { private static final String DEDUP_KEY_PREFIX alert:dedup:; private final AlertEventMapper alertEventMapper; private final DutyService dutyService; private final NotificationService notificationService; private final StringRedisTemplate stringRedisTemplate; public AlertService(AlertEventMapper alertEventMapper, DutyService dutyService, NotificationService notificationService, StringRedisTemplate stringRedisTemplate) { this.alertEventMapper alertEventMapper; this.dutyService dutyService; this.notificationService notificationService; this.stringRedisTemplate stringRedisTemplate; } Transactional public String ingest(AlertIngestRequest request) { Boolean first stringRedisTemplate.opsForValue() .setIfAbsent(DEDUP_KEY_PREFIX request.getEventNo(), 1, Duration.ofMinutes(5)); if (Boolean.FALSE.equals(first)) { return request.getEventNo(); } AlertEvent exist alertEventMapper.selectOne( new LambdaQueryWrapperAlertEvent() .eq(AlertEvent::getEventNo, request.getEventNo()) .last(LIMIT 1)); if (exist ! null) { return exist.getEventNo(); } DutyRotation duty dutyService.findPrimary(request.getTeamCode(), LocalDate.now()); AlertEvent event new AlertEvent(); event.setEventNo(request.getEventNo()); event.setTitle(request.getTitle()); event.setContent(request.getContent()); event.setLevelCode(request.getLevelCode()); event.setSourceSystem(request.getSourceSystem()); event.setStatus(0); event.setCurrentOwner(duty null ? null : duty.getUserId()); event.setEscalationRound(1); event.setCreateTime(LocalDateTime.now()); event.setUpdateTime(LocalDateTime.now()); alertEventMapper.insert(event); if (duty ! null) { notificationService.sendNotify(event, duty, 1); } return event.getEventNo(); } }Redis 去重和数据库唯一键去重是两层保护。Redis 负责挡住 5 分钟内的重复请求数据库唯一键负责防止极端情况下的重复插入。去重时间窗口要结合上游告警频率设计太短会刷屏太长会吞掉真正的新故障。4.5 对外接口接入接口只接收 JSON返回事件号。确认接口见第 5 章。package com.example.ack.controller; import com.example.ack.dto.AlertIngestRequest; import com.example.ack.dto.Result; import com.example.ack.service.AlertService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/alert) public class AlertController { private final AlertService alertService; public AlertController(AlertService alertService) { this.alertService alertService; } PostMapping public ResultString ingest(Valid RequestBody AlertIngestRequest request) { String eventNo alertService.ingest(request); return Result.success(eventNo); } }请求体 DTO 加上校验注解避免空 eventNo、空 title 落到数据库。package com.example.ack.dto; import jakarta.validation.constraints.NotBlank; import lombok.Data; Data public class AlertIngestRequest { NotBlank(message eventNo 不能为空) private String eventNo; NotBlank(message title 不能为空) private String title; private String content; private Integer levelCode 3; private String sourceSystem; NotBlank(message teamCode 不能为空) private String teamCode; }这里把teamCode作为必填字段。虽然它不属于告警本身但系统必须知道这条告警该路由到哪个团队的值班人。5. 确认与升级让“快接电话”变成系统规则5.1 确认接口确认接口必须使用条件更新。确认时要同时满足“事件存在”和“状态还是待确认”否则可能覆盖已经升级的事件状态。package com.example.ack.controller; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.example.ack.domain.AlertEvent; import com.example.ack.dto.Result; import com.example.ack.mapper.AlertEventMapper; import com.example.ack.service.NotificationService; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.bind.annotation.*; import java.time.Duration; import java.time.LocalDateTime; RestController RequestMapping(/api/alert) public class AckController { private final AlertEventMapper alertEventMapper; private final NotificationService notificationService; private final StringRedisTemplate stringRedisTemplate; public AckController(AlertEventMapper alertEventMapper, NotificationService notificationService, StringRedisTemplate stringRedisTemplate) { this.alertEventMapper alertEventMapper; this.notificationService notificationService; this.stringRedisTemplate stringRedisTemplate; } PostMapping(/{eventNo}/ack) public ResultVoid ack(PathVariable String eventNo, RequestParam String user) { String lockKey alert:acklock: eventNo; Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, user, Duration.ofSeconds(30)); if (Boolean.FALSE.equals(locked)) { return Result.fail(该事件正在被确认请稍后重试); } int updated alertEventMapper.update(null, new LambdaUpdateWrapperAlertEvent() .eq(AlertEvent::getEventNo, eventNo) .eq(AlertEvent::getStatus, 0) .set(AlertEvent::getStatus, 1) .set(AlertEvent::getAckUser, user) .set(AlertEvent::getAckTime, LocalDateTime.now()) .set(AlertEvent::getUpdateTime, LocalDateTime.now())); return updated 1 ? Result.success() : Result.fail(事件不存在或已处理); } }Redis 锁在这里不是分布式锁的最佳实践它只用于防止同一个事件被多个确认请求同时处理30 秒 TTL 足够不必引入 Redisson。5.2 超时升级扫描任务升级逻辑放在一个定时任务里固定间隔扫描待确认事件。为了让演示方便等待时间可以配置成 10 到 30 秒。package com.example.ack.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.example.ack.domain.AlertEvent; import com.example.ack.domain.EscalationPolicy; import com.example.ack.mapper.AlertEventMapper; import com.example.ack.mapper.EscalationPolicyMapper; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.List; Component public class EscalationScheduler { private final AlertEventMapper alertEventMapper; private final EscalationPolicyMapper escalationPolicyMapper; private final DutyService dutyService; private final NotificationService notificationService; public EscalationScheduler(AlertEventMapper alertEventMapper, EscalationPolicyMapper escalationPolicyMapper, DutyService dutyService, NotificationService notificationService) { this.alertEventMapper alertEventMapper; this.escalationPolicyMapper escalationPolicyMapper; this.dutyService dutyService; this.notificationService notificationService; } Scheduled(fixedDelay 30_000, initialDelay 10_000) public void scanUnackedEvent() { ListAlertEvent unacked alertEventMapper.selectList( new LambdaQueryWrapperAlertEvent() .eq(AlertEvent::getStatus, 0)); for (AlertEvent event : unacked) { String role event.getEscalationRound() 1 ? PRIMARY : BACKUP; EscalationPolicy policy escalationPolicyMapper.selectOne( new LambdaQueryWrapperEscalationPolicy() .eq(EscalationPolicy::getLevelCode, event.getLevelCode()) .eq(EscalationPolicy::getTargetRole, role) .last(LIMIT 1)); if (policy null) { continue; } long waitedSeconds java.time.Duration.between(event.getUpdateTime(), LocalDateTime.now()).getSeconds(); if (waitedSeconds policy.getWaitSeconds()) { continue; } if (event.getEscalationRound() policy.getMaxRound()) { continue; } int updated alertEventMapper.update(null, new LambdaUpdateWrapperAlertEvent() .eq(AlertEvent::getEventNo, event.getEventNo()) .eq(AlertEvent::getStatus, 0) .set(AlertEvent::getEscalationRound, event.getEscalationRound() 1) .set(AlertEvent::getUpdateTime, LocalDateTime.now())); if (updated 1) { notificationService.sendRound(event); } } } }这里最容易被忽略的是条件更新。如果没有.eq(AlertEvent::getStatus, 0)定时任务读取到的事件可能刚被确认但另一个线程仍然把它升级了用户就会在确认后继续收到升级通知。5.3 升级策略配置升级策略是整套规则的参数来源建议放数据库或配置中心不要硬编码。参数含义示例值调大影响调小影响wait_seconds本轮等待确认时间300给值班人更多时间但故障响应变慢响应更快但误报打扰增加max_round最大升级轮数3兜底更晚依赖人工介入升级过早可能打扰高层target_role目标角色PRIMARY/BACKUP影响分派对象同上channel通知渠道webhook可换邮件、短信渠道越少到达率越低最小策略数据可以这样初始化wait_seconds用 10 秒方便本地验证升级链路INSERT INTO escalation_policy (level_code, wait_seconds, max_round, target_role, channel) VALUES (1, 10, 3, PRIMARY, webhook), (1, 30, 3, BACKUP, webhook), (2, 300, 2, PRIMARY, webhook), (3, 600, 1, PRIMARY, webhook);P1 和 P2/P3 的等待时间差异很大这是合理的。级别越高的告警升级节奏要越快但也不能快到每 30 秒就轰炸一次。推荐 P1 轮次间隔 5 到 10 分钟P2 间隔 30 分钟左右具体要和团队响应 SLA 对齐。5.4 Redis Key 与状态缓存设计Key用途建议 TTLalert:dedup:{eventNo}同事件短时间去重5 分钟alert:acklock:{eventNo}防止重复确认30 秒数据库才是状态的最终依据Redis 只承担去重和防并发。不要把“值班人是谁”只存在 Redis因为 Redis 重启后只能重新查表也不要把升级轮数只维护在内存变量里因为服务重启后会丢失轮数导致重复升级。6. 运行验证模拟一次完整告警生命周期6.1 准备一个本地 webhook 接收端先用一个最小 Python 服务模拟 IM 机器人的 webhook 接收端。它把收到的通知打印到控制台并返回成功。from flask import Flask, request app Flask(__name__) app.route(/webhook/ops, methods[POST]) def webhook(): data request.get_json(forceTrue) print(收到通知:, data) return {code: 0, message: ok} if __name__ __main__: app.run(host0.0.0.0, port9000)启动后访问http://localhost:9000/webhook/ops能收到 POST 即可。这个服务只用于本地验证生产环境请对接企业 IM 机器人的真实 webhook 地址。6.2 初始化值班数据把work_date换成验证当天的日期。INSERT INTO duty_rotation (team_code, user_id, user_name, notify_target, work_date, is_primary) VALUES (sre-1, zhangsan, 张三, http://localhost:9000/webhook/ops, 2024-06-12, 1), (sre-1, lisi, 李四, http://localhost:9000/webhook/ops, 2024-06-12, 0);6.3 模拟发送一次 P1 告警curl -X POST http://localhost:8080/api/alert \ -H Content-Type: application/json \ -d { eventNo: P1-20240612-001, title: 订单服务可用率低于 99%, content: 近 5 分钟可用率 97.2%错误率持续上升, levelCode: 1, sourceSystem: prometheus, teamCode: sre-1 }预期响应返回P1-20240612-001。同时本地 Python 服务应打印第一轮通知数据。检查数据库mysql -uroot -p alert_ack -e SELECT event_no,status,current_owner,escalation_round FROM alert_event; mysql -uroot -p alert_ack -e SELECT event_no,channel,round,result FROM notification_record;正常结果是status0escalation_round1notification_record中有一条result1的记录。6.4 验证确认流程curl -X POST http://localhost:8080/api/alert/P1-20240612-001/ack?userzhangsan确认后检查mysql -uroot -p alert_ack -e SELECT event_no,status,ack_user,ack_time FROM alert_event WHERE event_noP1-20240612-001;status应变为1。此时即使再等 30 秒也不会有第二轮通知因为升级扫描任务里带了status0条件。6.5 验证超时升级再发送一条新事件然后什么都不做等待 40 秒左右。curl -X POST http://localhost:8080/api/alert \ -H Content-Type: application/json \ -d { eventNo: P1-20240612-002, title: 支付回调积压, content: 积压数超过 5000, levelCode: 1, sourceSystem: zabbix, teamCode: sre-1 }由于升级策略中 PRIMARY 等待 10 秒BACKUP 等待 30 秒这条事件会先收到一轮通知再在 40 秒后收到第二轮升级通知。本地 webhook 控制台会出现round1和round2两条记录。验证升级链路时不要把等待时间直接复制到生产。10 秒升级只适合本地演练生产环境按 5 到 10 分钟起步。7. 生产环境落地要补齐的工程能力与常见坑7.1 学习环境与生产环境的差异维度学习环境生产环境值班表手工 INSERT排班系统自动生成覆盖节假日通知渠道本地 webhook企业 IM 机器人、邮件、合规语音通道发送方式同步 REST异步线程池或消息队列削峰配置管理application.yml环境变量、配置中心审计查表即可长期归档、操作日志、权限隔离自身监控无健康检查、渠道存活探测、告警自身告警数据库单机主从、备份、慢查询监控不要把学习环境的同步发送直接搬上生产。外部 webhook 一旦变慢告警接入接口会被拖垮反而造成新告警进不来。生产环境至少要把NotificationService.sendNotify放入独立线程池或接入 MQ。7.2 常见问题排查问题现象常见原因检查方式处理建议通知没有发出日志显示 webhook 失败目标地址不可达或不接受 JSON用 curl 单独 POST 该地址检查地址、证书、IP 白名单和签名确认后仍收到升级提醒升级任务读到旧数据或没有条件更新查看 update_time 和 ack_time确认与升级都使用条件更新同一条告警反复通知event_no 生成规则不稳定查看去重 key 是否命中用固定事件 ID 或指纹去重 TTL 覆盖抖动窗口P3 告警也触发升级策略表没有按级别区分查询 escalation_policy为每个级别配置独立 wait_seconds跨天时值班人取错应用时区与数据库时区不一致检查 work_date 和 JVM 时区统一 Asia/Shanghai日期用 LocalDate服务重启后升级轮数错乱轮数只存在内存查看 escalation_round轮数持久化到 alert_event 表7.3 发布前检查清单上线前按这个清单逐项确认值班表是否已经生成到测试日期是否覆盖交接班边界。每个告警级别在 escalation_policy 中都有对应策略不允许漏配。event_no 有稳定生成规则同一个故障抖动时保持同一个值。通知渠道的超时时间、重试次数、失败告警都配置完整。确认接口和升级任务都做了条件更新不会互相覆盖。通知发送已异步化接入接口不会被外部渠道拖慢。清理掉本地演示用的 10 秒升级策略改成生产值。Redis 和 MySQL 时钟偏差在可接受范围避免超时判断失真。通知系统本身有健康检查渠道长时间失败能主动告警。所有配置外置化不发版也能调整值班表和策略。7.4 常见坑与扩展方向最容易踩的三个坑要单独说明。第一把“发送成功”当成“已确认”。这是整个系统最核心的认知错误。webhook 返回成功只代表消息到达网关不代表值班人点击了确认。如果只记录发送结果系统就退化成了普通通知工具。第二升级时不做条件更新。定时任务和确认请求并发时如果没有status0条件已确认事件可能继续升级。正确做法是升级和确认都使用UPDATE ... WHERE event_no? AND status0的原子语义。第三通知发送放在请求线程里。监控平台在故障高峰期会并发推送大量告警同步调用 webhook 会让接入接口持续阻塞。正确做法是接入接口只落库发送动作交给异步线程或消息队列处理。扩展方向上这套系统至少有三个自然演进路径。一是接入更多渠道企业微信、飞书、钉钉机器人以及公司已采购的合规语音通知服务都可以通过实现ChannelAdapter接入二是与故障管理平台联动确认动作可以反向创建故障单事件状态也能同步给上游三是把值班表升级成可视化日历支持临时换班、多人共同值班和按技能标签路由。对新手来说最有价值的练习是先把这个最小闭环跑通再自己加一个邮件渠道实现观察渠道新增时哪些类需要改、哪些类不需要动。这个观察过程比堆更多框架更有意义。
