💡接一个 webhook 有多简单?一个 POST、一段 JSON,五分钟跑通。但几乎所有团队的"消息通知"模块,最后都会变成同一副样子:一处业务代码里塞着三个平台的 access_token,群里半夜被告警刷了 800 条,同一个订单状态发了两遍,以及一条永远不会有人发现的——通知服务挂了 40 分钟,没人知道。
问题不在于 HTTP 调用本身,而在于我们习惯把它当成一次"普通的外部调用"来写。可消息通知这件事有个特殊属性:它的失败不会体现在业务返回值上。下单成功了但告警没发出去,接口照样返回 200,你要等到事故没人响应才会发现。
这篇把传统业务代码里通知链路的六个关键点讲透:事务一致性、出站签名、入站验签、重试与幂等、限流与聚合、以及多平台内容方言。都是能直接抄进项目的写法。
1. 先把链路拆开:六件事分别归谁管
很多团队的通病,是把这六层全写在同一个sendDingTalk()方法里。
业务事件 --> [1]Outbox --> [2]通知服务 --> [3]渲染模板 --> [4]治理(频控/聚合) | [5]通道适配器 --> [6]回执与重投 | 企微 / 钉钉 / 飞书 / 邮件 / 短信| 层 | 职责 | 放哪 | 常见错误 |
|---|---|---|---|
| 1 Outbox | 保证"业务成功 ⇒ 通知一定发" | 与业务同库同事务 | 业务事务里直接调 HTTP |
| 2 通知服务 | 收件人解析、场景路由 | 独立服务 | 散落在各个业务模块 |
| 3 渲染 | 模板 + 各方言适配 | 服务层 | 把文案拼进 SQL / Controller |
| 4 治理 | 去重、聚合、静默、限流 | 服务层 | 普遍缺失,导致刷屏 |
| 5 适配器 | 签名、重试、错误码归一 | 每通道一个实现 | if-else 平台分支满天飞 |
| 6 回执 | 落库、失败重投、可观测 | 队列 + DB | 只看 HTTP 200 |
第 1 层和第 4 层是最常被省掉的,也是出事故最多的两层。
把这条链路按层摊开,左边是每层管什么、放在哪,右边是同一层最常见的塌法:
2. 事务一致性:不要在业务事务里调 HTTP
这是最典型的一段"能跑但一定会出事"的代码:
@TransactionalpublicvoidcreateOrder(OrderDTOdto){orderMapper.insert(dto);stockService.deduct(dto.getSkuId());notifyService.sendDingTalk("新订单 "+dto.getId());// 危险}三个问题:事务回滚了消息已经发出去了(用户收到"下单成功"但没有订单);HTTP 超时把数据库连接占着不放,长事务直接拖垮连接池;通知失败会连带业务回滚,一个群机器人 500 导致用户下不了单。
正确做法是 Outbox 模式——把"要发什么"作为一行数据,跟业务写在同一个事务里:
@TransactionalpublicvoidcreateOrder(OrderDTOdto){orderMapper.insert(dto);stockService.deduct(dto.getSkuId());outboxMapper.insert(NotifyTask.builder().eventId("order.created:"+dto.getId())// 幂等键.scene("ORDER_NEW").payload(JSON.toJSONString(dto)).status(Init).nextRetryAt(LocalDateTime.now()).build());}然后一个独立的投递器(定时扫表,或事务日志 CDC 监听)把它推进消息队列:
业务事务 --> outbox 表 --> 投递器轮询 --> MQ --> 消费者(真正调 HTTP)--> 标记 sent 失败 --> backoff --> 超过阈值 --> 死信 + 人工幂等键是这里的灵魂。eventId加一个数据库唯一索引,重复投递自然被挡掉。注意别用 UUID 当 eventId——UUID 每次新生成,等于没有幂等。要用业务语义键(订单号 + 事件类型)。
把这套落点画在一起,左边是投递链路的五个站点和失败分支,右边是 outbox 那一行里的五个字段:
3. 出站签名:URL 里那个 key,本身就是凭证
群机器人的 webhook 地址通常长这样:.../webhook/send?key=6f2c…。这个 key 一旦泄露,任何人往你 200 人的员工群里发任意消息,且无法撤回、无法定位。
三家国内 IM 的安全机制对比:
| 平台 | 机制 | 强度 |
|---|---|---|
| 企业微信 | 仅 URL 里的key | 弱,只能靠"不外泄" |
| 钉钉 | 关键词 / IP 段 /加签三选一(可叠加) | 加签可用,timestamp 有效期 1 小时 |
| 飞书 | 关键词 / IP 白名单 /签名校验 | 签名带 timestamp,可防重放 |
钉钉加签的拼法(Java,官方口径):
publicstaticStringdingSign(Stringsecret,longtimestamp)throwsException{StringstringToSign=timestamp+"\n"+secret;Macmac=Mac.getInstance("HmacSHA256");mac.init(newSecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),"HmacSHA256"));byte[]signData=mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));returnURLEncoder.encode(Base64.getEncoder().encodeToString(signData),"UTF-8");}// https://oapi.dingtalk.com/robot/send?access_token=X×tamp=...&sign=...飞书看着一样,但 HMAC 的 key 和消息体是反的——这是最容易踩的坑:
importhashlib,hmac,base64,timedeffeishu_sign(secret:str,timestamp:int)->str:string_to_sign=f"{timestamp}\n{secret}"# 注意:以 string_to_sign 作为 KEY,对空字节串做 HMACh=hmac.new(string_to_sign.encode("utf-8"),digestmod=hashlib.sha256)returnbase64.b64encode(h.digest()).decode("utf-8")抄代码时如果把这两段互换,签名会永远校验失败,而报错误导性极强(往往只回一个sign match fail)。
至于密钥本身,三条硬规矩:
1) 从环境变量 / KMS / 配置中心读,绝不写进源码 2) 不进 Git:.env 加 .gitignore,CI 里用 secret store 3) 可轮换:出问题时能在 5 分钟内换 key 并让旧 key 失效第 3 条最容易被忽略,但群机器人没有 OAuth 的自动过期机制,轮换只能你自己实现。
4. 入站回调:验签、快回、异步、幂等
接 GitHub / 支付 / IM 的回调时,顺序永远是"先证明它真的是它"。
标准动作四步:
收到 POST 1 取原始 body(不要用解析后重新序列化过的对象) 2 验签:HMAC(secret, timestamp + "." + rawBody) == Header 里的签名 3 时间窗:|now - timestamp| < 5 分钟,否则拒绝(防重放) 4 立刻 200,把真正的处理丢进队列第 1 步是事故高发点。JSON 反序列化再序列化一次,key 顺序、空格、Unicode 转义全变了,签名必然对不上。Go 和 Node 里都要用原始字节。
第 4 步同样关键:回调方都有超时(常见 5~10 秒),超时会重试。如果你在 handler 里同步跑一个 3 秒的业务流程,就会收到 N 份重复事件。所以:
@PostMapping("/webhook/github")publicResponseEntity<Void>github(@RequestBodybyte[]raw,@RequestHeader("X-Hub-Signature-256")Stringsig,@RequestHeader("X-GitHub-Event")Stringevent,@RequestHeader("X-GitHub-Delivery")StringdeliveryId){if(!signatureVerifier.verify(raw,sig))returnResponseEntity.status(401).build();if(eventBus.exists(deliveryId))returnResponseEntity.ok().build();// 幂等:同一 delivery 只处理一次eventBus.save(deliveryId);queue.publish(newWebhookEvent(event,raw));// 异步returnResponseEntity.ok().build();// 快速返回}还有一个反直觉的点:入站事件是乱序的。GitHub 不保证pull_request.opened一定比pull_request.closed先到。所以处理逻辑要靠事件里的版本号 /updated_at判断,而不是靠到达顺序。幂等 + 单调更新(只接受比当前状态更新的写入)是唯一的解法。
5. 治理:限流、聚合、抑制、静默
这一节是"通知系统"和"一段 POST 代码"的分水岭。
5.1 限流:令牌桶要按作用域建
各平台限制的作用域不同,桶就得按那个维度建:
钉钉自定义机器人 --> 每个机器人 20 条/分钟 --> key = robotId 飞书自定义机器人 --> 每个 hook 100/分钟 + 5/秒 --> 双桶:分钟桶 + 秒桶 Telegram --> 全局 30/秒,单 chat 1/秒 --> 全局桶 + chat 桶classTokenBucket:def__init__(self,rate_per_min:int,burst:int=1):self.rate=rate_per_min/60.0self.cap=max(burst,1)self.tokens,self.ts=float(self.cap),time.monotonic()defacquire(self,wait=True)->bool:whileTrue:now=time.monotonic()self.tokens=min(self.cap,self.tokens+(now-self.ts)*self.rate)self.ts=nowifself.tokens>=1:self.tokens-=1returnTrueifnotwait:returnFalsetime.sleep((1-self.tokens)/self.rate)被限流之后不要丢弃消息——宁可延迟 20 秒送达,也不要静默丢一条告警。丢弃只应该发生在"这条消息已经过期失去意义"的场景。
5.2 聚合与抑制:刷屏本身就是一种事故
告警风暴的常见形态是:一个数据库连接池打满,50 个服务各自发现自己的超时,于是群里瞬间 50 条。真正有用的信息只有一条。
原始事件 50 条 ↓ fingerprint = (service? 去掉) + alertname + 关键 label 分组聚合:同一指纹 5 分钟窗口内合并为 1 条 "XX 告警 重复 50 次" ↓ 抑制规则:已有 critical 未恢复 --> 同资源的 warning 全部静默 ↓ 静默窗口:发布窗口期 / 已知演练 --> silence 直接丢弃 ↓ 实际发送:1 条Alertmanager的group_by/inhibit_rules/silence三个概念就是这套逻辑的标准实现,自己写也照这个模型抄。
5.3 降级:一条消息多通道兜底
P0 --> 电话 + 短信 + IM(@所有人) 任一成功即停止 P1 --> IM 卡片 + App 推送 主通道失败则降级 P2 --> 仅 IM 群 失败只记日志优先级决定通道的"打扰强度",也决定失败后要不要换路。把所有告警都设成 P0 的团队,最后会得到一个所有人都屏蔽掉的群。
6. 内容与渲染:三家 IM 的 markdown 不通用
这是最琐碎但最容易被低估的一层。
| 能力 | 企业微信群机器人 | 钉钉 | 飞书 | Slack |
|---|---|---|---|---|
| 标题/加粗/链接 | 支持 | 支持 | 支持 | 支持 |
| 表格 | 基本不行 | 不行 | 不行 | 不行(要用 Block) |
| 代码块高亮 | 受限 | 受限 | 受限 | 支持 |
| @指定人 | text用mentioned_list(markdown不支持 @) | text里@手机号 | 卡片<at id=…> | <@U…> |
| 按钮/交互 | 需模板卡片 | actionCard | interactive 卡片 | Block Kit |
| 单条大小 | 约 4096 字节 | 约 20000 字节 | 20480 字节 | 40000 字符 |
结论是:必须有一层"内部统一结构 → 各方言渲染"的适配,而不是让业务直接拼字符串。
publicrecordNotifyMessage(Stringtitle,MDLevellevel,List<KV>fields,StringlinkUrl,Set<String>atUsers){}publicinterfaceChannelRenderer{NotifyPayloadrender(NotifyMessagemsg);}// FeishuRenderer -> interactive card JSON// DingTalkRenderer-> actionCard JSON// WeComRenderer -> markdown,表格退化成 "key: value" 换行两个实践细节:
超长一定要截断并给跳转链接,别让平台返回一个-1001 invalid message:
if(body.length()>2000)body=body.substring(0,2000)+"\n… [查看完整日志]("+detailUrl+")";通知的价值在于"可跳转",不在于"信息全"。一条只有三行摘要 + 一个深链的卡片,实际处理效率远高于贴了 200 行堆栈的纯文本。深链要直达具体的 trace / job / 订单页,而不是首页。
7. 我踩过的坑,一次列完
Q1:HTTP 返回 200,是不是就代表消息发出去了?
不是。国内几家 IM 机器人都是HTTP 200 + 业务错误码:企业微信限流回{"errcode":45009,"errmsg":"reach out max qps"},钉钉超限回430100一类的封禁码,飞书则在code字段里回限流错误。只看状态码的实现对"被限流"这件事完全无感,于是消息静默丢失。一定要解析业务码(字段名还不一样:企微与钉钉是errcode,飞书是code),并把非零码当作失败进入重试。具体码值以各家控制台当前文档为准,但**"200 不等于送达"这个结论是通用的**。
Q2:重试应该重试几次、怎么退避?
指数退避 + 抖动,上限 5~6 次,总时长控制在分钟级:
1s → 2s → 4s → 8s → 16s → 32s,每次乘 random(0.5, 1.5)必须区分可重试与不可重试:45009(限流)可重试;签名错误、参数非法、机器人被移出群是配置问题,重试一万次也一样失败,应该直接进死信并告警——而且要告到另一个通道,因为它所在的那个通道已经不可用了。
把这套判定横着排一遍:顶部是失败判定的依据,中间是退避节奏的时间轴,底下是两栏分流:
Q3:通知要不要记录"已读"?
看用途。IM 群机器人拿不到已读回执,这一点决定了它不适合做"必须确认"类通知。真要闭环,得走应用消息 + 卡片按钮回调,或者干脆在值班平台里做 Ack。别在架构评审时许诺"我们能看到谁读了通知"。
Q4:多个环境(测试/预发/线上)怎么避免串群?
把 webhook 地址做成配置项,并在消息模板里强制注入环境标识:
【预发】订单创建失败:…这一行前缀能省掉一半"到底哪套环境出事了"的互相@。
最后总结
- 不要在业务事务里调 HTTP,用 Outbox + 幂等键(业务语义键,不是 UUID)。
- URL 里的 key 就是凭证,必须能轮换;钉钉用
timestamp+"\n"+secret当消息体,飞书把它当 HMAC 的 key——这两个千万别抄反。 - 入站先验签、快回 200、异步处理,并且接受"事件乱序"这个前提。
- 限流桶按平台作用域建,被限流宁可延迟不要丢弃。
- 聚合、抑制、静默是通知系统的一半价值;告警风暴造成的伤害,往往比它要报告的那个故障更大。
- 解析业务错误码,把不可重试错误导向死信,并且用另一条通道告警。
一句话结论:通知模块的正确定位不是"一段 HTTP 调用",而是"一个有队列、有幂等、有治理、有多通道降级的独立子系统"。
下一篇换个视角:当调用方从"确定的业务代码"变成"不确定的大模型",这套东西要重想一遍——也就是给 Agent 装上嘴巴。
参考资料 & 致谢
[1] 钉钉开放平台-加签与验签算法
[2] 飞书开放平台-自定义机器人安全设置
[3] 企业微信开发者中心-群机器人
[4] Slack-Verifying your requests from a webhook
[5] GitHub Webhooks-Securing webhooks
[6] Stripe Webhooks-Signature verification
[7] Prometheus Alertmanager 文档
[8] Chris Richardson-Outbox Pattern
[9] Google Cloud-Site Reliability Engineering: Alerting on SLOs