最近我花了一晚上把 Sentinel 的告警通知给接进了企业微信和钉钉群,触发限流熔断的时候,群机器人直接把资源名、异常类型、QPS、阈值这些关键信息全部抛出来,再也不用盯着控制台刷监控了。这套东西本身不复杂,核心就是 Webhook:Sentinel 负责触发事件,应用侧捕获异常或监听规则变更,组装成 Markdown 消息,POST 到企业微信/钉钉机器人的地址上,群里的值班同学第一时间就能看到。
如果你手头的项目正在用 Sentinel,又苦于限流被触发时没有实时感知,这篇内容应该能直接给你省下不少排查时间。我会从机器人创建、Sentinel 客户端改造、消息封装、防抖和重试这几个环节一步步讲,最后附上我踩过的几个坑和排查思路。
1. 告警这件事,值得单独折腾一下
1.1 Sentinel 的默认能力,离“告警”还差一步
Sentinel 是阿里开源的流量治理组件,主打限流、熔断降级和系统负载保护。它的默认行为很明确:规则命中后抛BlockException,同时会在本地打印sentinel-block.log日志,实时状态也会在 Dashboard 的监控页面上展示。但这离真正的“告警”还有一段距离——日志是事后翻的,控制台是主动去盯的,线上应用真正被限流的时候,开发和运维往往只能等用户投诉进来才知道出事了。
我接手的几个核心服务都是 Sentinel 的老用户了,限流规则配了一大堆,但告警通知完全是空白。偶尔有接口被刷爆,QPS 把阈值撞穿,业务方反馈“怎么突然请求大量失败”,我们才后知后觉地去查日志,发现FlowException已经刷了上千行。这个反应链路实在太长,所以这次下定决心把告警补齐。
1.2 为什么要通过 Webhook 而不是把消息系统做重
先说结论:Webhook 是性价比最高的轻量方案。你只需要一个 HTTP POST 接口,把 JSON 消息体推过去,IM 群里的机器人就会帮你把内容展示出来。相比自建消息推送平台、接入短信服务或者搭建 Prometheus + Alertmanager 全套监控链路,Webhook 几乎零成本,不引入额外存储,也不用运维额外组件,适合绝大多数中小团队和业务类系统。
有人可能会说,直接上 Prometheus + Alertmanager 不是更专业?确实,如果你们已经有完整的可观测性体系,那走 Alertmanager 或夜莺这类平台统一出告警是正道。但很多项目的体量并没有那么大,硬塞一套监控体系进来反而增加维护成本。Sentinel 的告警通过 Webhook 直连企业微信/钉钉群,属于“四两拨千斤”的做法,规则命中即推送,延迟基本在秒级,够用且好维护。
2. 先把两个 IM 的机器人 Webhook 摸透
2.1 企业微信群机器人:三分钟拿到 Webhook 地址
企业微信的群机器人配置路径很直接:进入目标群,打开群设置,找到“群机器人”,点击“添加机器人”,选一个新建或者用已有的,就能拿到一个 Webhook 地址。地址格式类似:
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的唯一标识这里要提醒两点。第一,机器人名称建好之后是可以改的,建议直接命名为“Sentinel告警机器人”,这样在群里一眼能认出来。第二,企业微信机器人的安全策略主要是“关键词”和“IP白名单”,如果你在创建机器人时配置了关键词,那么推送的文本或 Markdown 内容里必须包含至少一个关键词,否则消息会被静默丢弃,接口返回成功但群里永远看不到内容。这个坑我后面会展开说。
发送消息时,只需要以application/json的格式 POST 到上面这个地址。企业微信支持text、markdown、news等多种消息类型,告警场景下用markdown最合适,加粗、高亮、链接都能渲染,信息层级一目了然。
2.2 钉钉自定义机器人:加签才是真正的门槛
钉钉的入口在群里的“智能群助手” -> “添加机器人” -> “自定义”。创建时有三种安全设置:自定义关键词、加签、IP地址段。关键词和 IP 限制跟企业微信类似,而加签是钉钉特有的,也是很多人第一次接就卡住的地方。
加签的逻辑是:把当前毫秒级时间戳加上换行符再加上机器人密钥,拼成一个字符串,然后用 HMAC-SHA256 算法计算出签名,做 Base64 编码,再对签名结果做 URL 编码,最后把timestamp和sign拼到 Webhook 地址的 query 参数上。注意几个细节:
- 时间戳必须是毫秒级,和 URL 上的
timestamp参数保持一致,钉钉会校验时间偏差,超出后直接报sign not match。 - 拼接格式是
timestamp + "\n" + secret,反了或者漏了换行符都会签名失败。 - 签名做完之后必须
URLEncoder.encode一下,否则 Base64 里的+、/、=会让签名解析出问题。
加签方式的 Webhook 地址类似:
https://oapi.dingtalk.com/robot/send?access_token=你的token×tamp=1700000000000&sign=xxxxx2.3 Webhook 消息格式速查与差异对比
下面是我实际封装消息时整理的对照表,两家机器人虽然都是 Markdown 消息,但字段名有些不同:
| 平台 | 消息类型 | 请求体结构 | 返回成功标志 |
|---|---|---|---|
| 企业微信 | markdown | {"msgtype": "markdown", "markdown": {"content": "..."}} | {"errcode":0,"errmsg":"ok"} |
| 钉钉 | markdown | {"msgtype": "markdown", "markdown": {"title": "...", "text": "..."}} | {"errcode":0,"errmsg":"ok"} |
这里最容易踩的坑是:企业微信的 Markdown 字段叫content,钉钉的 Markdown 字段叫text,并且钉钉还要求有一个title字段。如果你把企业微信的请求体原封不动搬到钉钉,接口会返回错误,群里面什么也不显示。另外,两家对 Markdown 语法支持也有细微区别,比如企业微信对@所有人的写法支持得更纯粹,钉钉在 Markdown 文本里需要特定语法,所以我建议告警内容里不要依赖 @ 功能,直接靠内容本身说清楚问题更稳妥。
3. Sentinel 告警方案设计:从触发到通知的全链路
3.1 方案一:业务代码里手动处理 BlockException
最朴素的做法是在业务代码捕获BlockException。用 Sentinel 的原生 API:
try (Entry entry = SphU.entry("queryOrder")) { // 业务逻辑 return orderService.query(...); } catch (BlockException ex) { notifyClient.send(ex, "queryOrder"); return fallbackResult(); }这样说白了就是把告警逻辑硬编码在业务方法里。优点是简单直接,但缺点也很明显:项目里受 Sentinel 保护的资源可能有几十上百个,每个都手动 catch,代码会非常啰嗦,而且很容易漏掉某几个资源的告警。我只建议在个别核心接口上临时用这种方式,不适合作为统一方案。
3.2 方案二:用 AOP 切面统一拦截(推荐)
如果项目中大量使用了@SentinelResource注解,那么用一个 AOP 切面把资源方法的调用包起来,捕获到异常时统一发告警,是目前侵入性最小、扩展性最好的方式。切面代码如下:
@Aspect @Component public class SentinelAlertAspect { private final WebhookNotifier webhookNotifier; public SentinelAlertAspect(WebhookNotifier webhookNotifier) { this.webhookNotifier = webhookNotifier; } @Around("@annotation(com.alibaba.csp.sentinel.annotation.SentinelResource)") public Object handleSentinelBlock(ProceedingJoinPoint joinPoint) throws Throwable { try { return joinPoint.proceed(); } catch (BlockException e) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); String resource = signature.getName(); webhookNotifier.send(buildMessage(e, resource)); throw e; } } }这里有几个点需要注意。第一,切点表达式中的@annotation写法要求方法上明确加了@SentinelResource注解,如果你的团队习惯用SphU.entry原生 API,切面就帮不上忙,需要回到方案一或者做一层自定义封装。第二,切面捕获到BlockException之后必须重新抛出去,否则会破坏 Sentinel 原本的降级语义。第三,理论上joinPoint.proceed()抛出的异常不一定是BlockException,所以 catch 范围要精确,别把业务异常也当成限流告警发出去。
我推荐这个方案还有一个原因:告警逻辑完全收敛在切面里,后续想调整消息模板、加静默期、做告警聚合,都只需要改这一个类,其他业务代码完全不用动。
3.3 方案三:监听规则变更,把“动作”也通知出去
限流本身会触发告警,但规则被人改了之后,最好也能在群里留个痕。Sentinel 的规则加载机制支持注册监听器:当规则通过控制台或数据源推送到客户端时,监听器会收到回调。利用这一点,可以把规则变更记录一起推到群里。
下面给出一段监听流控规则变更的代码示例:
@Component public class FlowRuleChangeListener implements InitializingBean { @Override public void afterPropertiesSet() { FlowRuleManager.getProperty().addListener(new PropertyListener<List<FlowRule>>() { @Override public void configLoad(List<FlowRule> value) { notify("规则加载", value); } @Override public void configUpdate(List<FlowRule> value) { notify("规则更新", value); } }); } private void notify(String action, List<FlowRule> rules) { // 组装规则数量、资源名和阈值,推送到 Webhook } }这段代码看起来不复杂,却是我后来在运维中最依赖的一个功能。因为限流规则经常被临时调整,如果没有变更留痕,过几天根本想不起来哪个资源当时为什么放宽了阈值。规则变更提醒和限流触发提醒配合起来,告警群里才能还原完整的“发生了什么”和“为什么发生”。
3.4 为什么不建议走 Sentinel 日志采集这条路
有的人不想动 Java 代码,想着 Sentinel 本来会写sentinel-block.log日志,那我用 Filebeat 采集日志,转发到 Logstash 再解析,最后调用 Webhook,不是更解耦吗?
理论上完全可行,但我不建议一上来就这么搞。原因有三:第一,日志解析有延迟,尤其是大流量秒杀场景下,日志文件瞬间膨胀,采集器处理不过来,告警会严重滞后;第二,告警需要的是结构化数据,日志里虽然包含了时间、资源名、异常类型,但每次推送前你还得清洗字段,链路越长越容易出差错;第三,直接抓日志拿不到“哪些规则被变更”这类控制面事件。所以日志采集方案更适合那些有现成日志平台、实在不愿意动代码的团队,而不是通用首选。
4. Webhook 通知模块的完整实现
4.1 配置文件与实体
为了让通知模块不写死在代码里,我用 Spring Boot 的配置文件管理各环境的机器人地址和密钥。以钉钉为例:
alert: dingtalk: webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx secret: SECxxxxxx enabled: true wecom: webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx enabled: true对应的配置实体:
@Component @ConfigurationProperties(prefix = "alert") @Data public class AlertProperties { private DingTalk dingtalk = new DingTalk(); private WeCom wecom = new WeCom(); @Data public static class DingTalk { private String webhook; private String secret; private boolean enabled; } @Data public static class WeCom { private String webhook; private boolean enabled; } }用@ConfigurationProperties的好处是后续加字段不需要改注入点,而且多个环境可以配不同的值。生产环境的 Webhook 地址属于敏感信息,建议放在环境变量或配置中心里,不要直接提交到 Git 仓库,这个我会在最后的安全部分再提。
4.2 企业微信/钉钉通知发送器实现
发送器的核心就是构建 JSON 字符串、发送 HTTP POST、解析返回结果。我直接用 Spring 的RestTemplate实现,并发场景下单例够用。先看企业微信发送器:
@Component public class WeComNotifier { @Resource private AlertProperties alertProperties; public void send(String markdownContent) { if (!alertProperties.getWecom().isEnabled()) { return; } Map<String, Object> message = new HashMap<>(); message.put("msgtype", "markdown"); Map<String, String> markdown = new HashMap<>(); markdown.put("content", markdownContent); message.put("markdown", markdown); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<Map<String, Object>> entity = new HttpEntity<>(message, headers); // 企业微信机器人每分钟限制20条,失败重试只做2次 try { String body = restTemplate.postForObject(alertProperties.getWecom().getWebhook(), entity, String.class); log.info("wecom alert response: {}", body); } catch (Exception e) { log.error("wecom alert failed", e); } } }钉钉发送器需要额外完成加签逻辑:
@Component public class DingTalkNotifier { @Resource private AlertProperties alertProperties; public void send(String markdownContent) { if (!alertProperties.getDingtalk().isEnabled()) { return; } Map<String, Object> message = new HashMap<>(); message.put("msgtype", "markdown"); Map<String, String> markdown = new HashMap<>(); markdown.put("title", "Sentinel 告警"); markdown.put("text", markdownContent); message.put("markdown", markdown); String webhookUrl = buildSignedUrl(alertProperties.getDingtalk().getWebhook(), alertProperties.getDingtalk().getSecret()); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<Map<String, Object>> entity = new HttpEntity<>(message, headers); try { String body = restTemplate.postForObject(webhookUrl, entity, String.class); log.info("dingtalk alert response: {}", body); } catch (Exception e) { log.error("dingtalk alert failed", e); } } private String buildSignedUrl(String webhook, String secret) { Long timestamp = System.currentTimeMillis(); String stringToSign = timestamp + "\n" + secret; try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] signData = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)); String sign = URLEncoder.encode(Base64.getEncoder().encodeToString(signData), "UTF-8"); return String.format("%s%s×tamp=%d&sign=%s", webhook, webhook.contains("?") ? "&" : "?", timestamp, sign); } catch (Exception e) { throw new IllegalStateException("dingtalk sign failed", e); } } }拼接 URL 的时候要用contains("?")判断连接符号,我见过有人写死&,而 access_token 的 URL 本身带?,写死就翻车了。
4.3 消息模板设计:群里一眼能看懂发生了什么
告警消息最忌讳光秃秃一行字“接口被限流了”。我打磨后的模板长这样:
### Sentinel 告警 - **应用名**: order-service - **环境**: prod - **资源**: queryOrder - **异常类型**: 流控 FlowException - **当前QPS**: 325 - **配置阈值**: 100 - **触发时间**: 2024-12-20 14:32:11 > 请检查该接口是否存在异常流量,或确认是否需要临时调整规则。这个模板在企业微信和钉钉里都是标准的 Markdown 渲染,关键信息用加粗标出,便于值班同学第一时间扫到重点。实现时不需要把模板硬编码在代码里,可以用字符串模板拼接:
public AlertMessage buildMessage(BlockException ex, String resource) { long count = FlowRuleManager.getFlowRules().stream() .filter(r -> r.getResource().equals(resource)) .mapToLong(r -> r.getCount().longValue()) .findFirst().orElse(0L); return String.format("### Sentinel 告警\n\n- **应用名**: %s\n- **资源**: %s\n- **异常类型**: %s\n- **配置阈值**: %d\n- **触发时间**: %s", appName, resource, ex.getClass().getSimpleName(), count, now()); }这里要注意把应用名、环境这类基础信息放在配置里,不要每个告警消息里写死。不同环境用同一个告警群时,环境字段非常关键,否则一个测试环境的误报能骗走所有人。
4.4 异步发送、重试与防抖
告警发送不能阻塞业务线程。如果 Webhook 网络超时一两秒,接口响应可能因此变慢,所以发送动作一定要异步化。我用了 Spring 的@Async注解,配一个独立线程池:
@Async("alertExecutor") public void sendAlertAsync(AlertMessage message) { notifier.send(message); }线程池参数不用很大,核心线程 2、最大线程 4、队列容量 1000 足够,告警消息量通常不大,但峰值时不能拖垮业务线程。
重试逻辑我做得比较简单:发送失败后延迟 1 秒重试一次,最多重试两次,再失败就只写日志。因为告警消息本身就强调实时性,超过 3 秒再发,积累的意义就小了很多。真正需要重点防的是告警风暴。
假设某个接口突然被流量打爆,Sentinel 熔断后大概率是持续一段时间内每个请求都触发异常,如果不做限制,机器人会把群消息刷到爆炸。因此我在通知模块里加了一个按资源维度的全局防抖:同一个资源在 60 秒内只发一条告警。
private final ConcurrentHashMap<String, Long> lastNotifyTime = new ConcurrentHashMap<>(); private boolean shouldNotify(String resource) { long now = System.currentTimeMillis(); Long last = lastNotifyTime.putIfAbsent(resource, now); if (last == null || now - last > 60_000L) { lastNotifyTime.put(resource, now); return true; } return false; }60 秒的静默期是试出来比较舒服的节奏,既能感知问题存在,又不会刷屏。如果团队要求更严格,可以把间隔缩小到 30 秒,或者设计一个基于时间窗口的最大消息数。
5. 实测记录:模拟限流,看群里怎么报警
5.1 准备一个容易触发规则的测试接口
我在本地起了一个 Spring Boot 服务,引用了sentinel-core、sentinel-annotation-aspectj和sentinel-transport-simple-http,为了方便直接用 Dashboard 人工配流控规则。业务接口很简单:
@SentinelResource(value = "queryOrder") @GetMapping("/api/demo") public String demo() { return "ok"; }然后在 Dashboard 里给queryOrder配置一条流控规则,阈值设成 1,也就是每秒最多 1 个请求,超过的直接拦掉。阈值设这么低就是为了方便压测触发。
5.2 压测触发限流,观察告警消息
我用ab模拟并发请求:
ab -n 200 -c 20 http://localhost:8080/api/demo200 个请求,阈值 1,必然大量触发限流。此时再看企业微信告警群,已经收到了第一条 Markdown 消息,资源名、异常类型、阈值、触发时间都在。过了 1 秒,因为防抖生效,不会再有第二条,直到 60 秒静默期过后,如果还在压测,才会有下一条消息。而钉钉群也同时收到了同等内容的告警,说明两个发送器都正常工作。
实际测下来,从请求触发BlockException到群里收到消息,延迟基本在 300 到 500 毫秒内。这中间主要开销是 HTTP POST 的建连和消息渲染,对于告警来说已经足够实时。
5.3 消息没到?我把排查过程也记录下来
第一次测试的时候,企业微信群里怎么等都收不到消息,但接口返回的errcode确实是 0。这个现象很容易迷惑人。后来我才意识到,企业微信创建的机器人如果勾选了“关键词”,发送内容必须包含对应关键词。我当时填的关键词是“告警”,可是测试时模板里写的是“Sentinel Alert”,一个关键词都不匹配,于是消息被静默丢弃。改成在消息里强制拼接“告警”两个字之后就正常了。
钉钉那边也出过一次问题,现象是接口返回errcode: 310000,提示sign not match。排查后发现是服务器时间比真实时间快了一分多钟,导致时间戳校验不通过。解决方法是让容器走 NTP 时间同步,然后在代码里再做一次时钟偏移补偿。如果你们服务跑在虚拟机或 Docker 里,非常建议先检查时间同步。
6. 常见问题与避坑清单
6.1 机器人不推送的常见原因
我整理了一个速查表,按排查顺序排好了,遇到“群里没消息”直接对照着试:
| 现象 | 可能原因 | 排查和解决 |
|---|---|---|
| 接口返回 errcode=0,但群里无消息 | 企业微信机器人配置了关键词,消息不含关键词 | 在群机器人设置里确认关键词,在消息模板中强制带上 |
| 钉钉返回 sign not match | 加签时间戳不一致或签名算法写错 | 检查服务器时间,替换为真实时间戳后重新生成签名 |
| 返回 errcode 非 0 | Webhook 地址被篡改或拼接错误 | 打印完整 URL 检查 query 参数是否重复或缺失 |
| 群里偶尔有消息,经常没有 | 触发了机器人每分钟 20 条限流 | 降低发送频率,加重防抖逻辑,按资源聚合消息 |
| 消息内容显示乱码 | Markdown 字段混用了双平台格式 | 检查是不是把钉钉的 text 写到了企业微信的 content 上 |
6.2 告警风暴怎么控制
告警风暴是我接手后最先解决的问题之一。一开始没有防抖逻辑,瞬间的流量高峰直接把群里刷了上百条消息,手机震个不停。后来加了按资源 60 秒静默和滑动窗口统计,问题才缓解下来。如果你有多个服务共用一个告警群,建议把服务名或者环境作为第一级维度的防抖 key,再加资源名作为第二级维度,这样“同一个服务的同一个资源”在窗口期内只发一条,其他资源不受影响。
另外一个有效手段是给不同级别告警设置不同告警频率限流。比如规则变更通知永远实时发,而限流触发通知 60 秒最多一条,做到“百川入海各有阀门”,群里才能真正安静下来。
6.3 内网环境访问公网 Webhook 的问题
有些公司的服务部署在封闭内网,不能直接访问公网 Webhook 地址。这种情况下需要确认是否有统一的 HTTP 出口代理。我的做法是把代理地址配到RestTemplate的SimpleClientHttpRequestFactory里,这样应用就可以通过代理转发到企业微信或钉钉。如果公司链路管控更严格,建议走内部消息网关,由网关统一转发到外网,这样密钥也不用下发到每个服务上。
6.4 Webhook 地址泄露了怎么办
Webhook 地址本质上等于一个可以向群内发消息的凭证。一旦泄露到外部,别人就可以恶意往你们告警群里灌垃圾消息。所以我有几个习惯:机器人能设 IP 白名单就一定设;密钥和 Webhook 地址不要提交到 Git 仓库,运维通过环境变量注入;如果发现群里出现不明消息,第一时间去群机器人设置里删除并重建机器人。钉钉的 secret 和 access_token 也需要一并对换,只换一个等于没换。
另外从代码层面说,日志输出时不要打印完整 Webhook 地址和签名,避免日志平台泄露。我曾经在 Debug 日志里打过一次签名 URL,直接被日志采集系统收集到了 ELK,后来整改时才清掉,这个教训还是挺深刻的。
这个告警体系上线之后,我再也没有在半夜被用户反馈叫醒过。真要给这套方案排个优先级,我的建议是:先做 AOP 统一捕获限流异常,再做防抖和规则变更通知,最后才是优化消息模板和对接第二个 IM 平台。Sentinel 本身已经解决了“保护”的问题,Webhook 解决了“感知”的问题,两者配合起来,限流这件事才真正闭环了。