接收方合并去重:同一个人为什么只该收到一次通知

11 阅读7分钟

接收方合并去重:同一个人为什么只该收到一次通知

Alioth 技术笔记 · 第 12 篇 · 通知 / 去重 仓库:github.com/aide-family… | 在线演示:web.alioth.asia/

导语

故障发生时,真正让人开始忽略告警的往往不是告警本身太多,而是同一条告警重复出现:邮件一封、企业微信一条、值班群再一条,而它们说的是同一件事。重复通知比漏发更容易被容忍,但代价是注意力被消耗,最后所有人对告警"免疫"。Alioth 在通知链路上做了四层去重,这篇文章从上到下拆开:组内合并、跨组联系人、任务唯一键、时间窗抑制。

一、四层去重各管什么

层问题手段
组内同一个通知组里,手填邮箱、成员邮箱、值班人邮箱重复归一化后合并去重
跨组订阅和 OnCall 命中不同通知组,但联系人重叠dispatchTracker 记录已排期联系人
任务同一事件同一渠道被写入两次任务唯一约束 + 插入前预检
时间同一条告警每两秒触发一次,反复通知重复通知间隔(按指纹抑制)

四层是递进的:前三层在"生成任务"这一步解决,第四层在"生成任务之后"解决。时间窗那一层(第 09 篇讲过取最长间隔)是唯一会主动丢弃通知的一层,其余三层只是"不重复生成"。

二、组内:三份来源合并成一份

一个通知组的邮件接收方来自三个地方:手填邮箱、成员邮箱、当班轮换成员的联系方式。合并逻辑在 resolveGroupReceivers 的末尾:

seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
    value = strings.TrimSpace(value)
    if value == "" { continue }
    key := strings.ToLower(value)
    if _, ok := seen[key]; ok { continue }
    seen[key] = struct{}{}
    out = append(out, value)
}
return strings.Join(out, ","), nil

这里只做了小写归一化——因为这是同一个组内的合并,格式差异有限。真正的跨组归一化在下一层,规则更狠。

三、跨组:联系人级与渠道级两种去重

dispatchTracker 只维护两个 map:

type dispatchTracker struct {
    contacts          map[string]struct{}   // 已经排期过的邮箱/手机号
    scheduledChannels map[string]struct{}   // 已经排期过的 Webhook 渠道
}

判断逻辑按渠道类型分叉:

func (tracker *dispatchTracker) pendingTarget(target notifyTarget) (string, bool) {
    if target.ChannelType != SMTP && target.ChannelType != SMS {
        if _, ok := tracker.scheduledChannels[target.ChannelUID]; ok {
            return "", false      // 这个 Webhook 渠道已经排过任务
        }
        return target.Receivers, true
    }
    contacts := pkgnotify.SplitReceivers(target.Receivers)
    remaining := ...
    for _, contact := range contacts {
        if tracker.contactDelivered(contact) { continue }
        remaining = append(remaining, contact)
    }
    if len(remaining) == 0 { return "", false }
    return strings.Join(remaining, ","), true
}

为什么邮件按"人"去重、Webhook 按"渠道"去重?因为邮件的接收方是一串地址,可以裁剪;Webhook 的接收方是一个渠道配置(URL + 签名方式 + 应用类型),一条任务只能对应一个渠道。两个不同渠道指向同一个 URL 属于配置重复,系统不做猜测——那应该由配置者合并。

这个设计带来一个很好的性质:去重发生在生成任务之前。事后合并任务是很危险的:任务可能已经开始投递,删掉一条就等于"少发一个人",而这在事务里无法回滚。

四、归一化:+86 与不带国家码是同一人

normalizeContact 分两种:

value := strings.ToLower(strings.TrimSpace(contact))
if strings.Contains(value, "@") { return value }        // 邮箱:只小写
// 手机号:去掉空格、横线、括号,只留数字

手机号还要处理国家码。contactDelivered 里有一段:

// 手机号可能带或不带国家码,归一化后相同,或较短一方是较长一方的后缀(至少 8 位)
// 时视为同一联系人。
if strings.HasSuffix(known, key) || strings.HasSuffix(key, known) { return true }

+8613800138000 去掉符号后是 8613800138000,13800138000 是它的后缀,于是被判为同一个人。8 位下限防的是"短号码恰好是长号码后缀"的误判。

如果不做这层归一化,同一个值班人会在一个组里收到两封邮件——因为他的邮箱被手填了一次、又从成员列表里带出来一次,格式还不同(大小写、前后空格)。

五、顺序决定归属:订阅先占位

跨组去重里有一个隐含的顺序问题:订阅和 OnCall 命中同一个组,谁先占这个组?

// 按路由顺序处理:订阅命中的通知组排在 OnCall 之前,接收方重叠时订阅的
// 渠道和模板先占用联系人,与 subscription_uid 优先的归属保持一致。

订阅排在 OnCall 前面,是因为任务的 subscription_uid 字段记录了"这条通知是谁触发的"。如果让 OnCall 先占位,日志里会出现一条 OnCall 归属的记录,但用户实际是在订阅里配置的——排障时会对不上。

六、tracker 从已有任务初始化

这是最容易被忽略的一层保险:newDispatchTracker 不是从空 map 开始,而是先读同一事件、同一阶段已有的任务:

rows := tx.NotificationTask.Query().Where(
    notificationtask.EventUIDEQ(event.EventUID),
    notificationtask.PhaseEQ(notificationtask.Phase(event.Phase)),
).All(ctx)
for _, row := range rows {
    tracker.scheduledChannels[row.ChannelUID] = struct{}{}
    for _, contact := range pkgnotify.SplitReceivers(row.Receivers) {
        tracker.contacts[normalizeContact(contact)] = struct{}{}
    }
}

意义在于:事件处理可能重试,上一次只写入了一部分任务(比如某条 SQL 失败回滚了一部分)。第二轮重新分发时,tracker 知道"这些人已经排过队了",不会给同一个人再建一条任务。去重状态跨事务边界仍然有效,因为它来自数据库,而不是内存。

七、最后的兜底:唯一键与预检

即便前三层都漏了,任务表上还有唯一约束 (event_uid, phase, group_uid, channel_uid)。但代码没有把它当控制流:

exists, err := eventTaskExists(ctx, tx, event, group.UID, target.ChannelUID)
if exists { continue }
err = tx.NotificationTask.Create()...
if ent.IsConstraintError(err) { continue }

注释解释得很清楚:PostgreSQL 里唯一约束冲突会让整个事件事务失效,不能靠捕获错误继续。所以插入前先查一次,约束只当最后一道拦截——竞态极端情况下才会命中,命中后整体放弃这一条。

八、几个真实的坑

  1. 空的接收方要跳过。 一个 SMTP 目标如果算完接收方是空字符串,整个目标直接跳过,不能生成一条"发给零个人"的任务——否则消息日志里会出现一条永远失败的任务。
  2. 停用的渠道和模板不参与。 resolveGroupTargets 先按 ACTIVE + 未删除 过滤,模板还要 templateCoversChannel 匹配渠道类型,任何一个不满足就跳过该目标。
  3. 同一组被两套路由命中只处理一次。 路由合并时用 merged map[groupUID] 去重,order 数组保留首次出现的顺序,保证"先订阅后 OnCall"的确定性。
  4. 接收方是快照,不是实时查询。 任务里存的是当时算出来的 receivers 字符串。之后成员退租、换手机号,都不影响已排队的任务——这是有意的:通知内容必须可追溯,事后不能"改写历史"。

九、怎么验证

app/alioth/internal/data/notification_interval_test.go 里的 TestNormalizeContact 与 TestNotificationDispatchRecipientDedupe 正好覆盖了上面两节的归一化与跨组去重;pkg/notify/receivers_test.go 覆盖分隔符与空白处理。手工验证:

git clone https://github.com/aide-family/moon.git && cd moon
make dev-up
open http://127.0.0.1:8080

建两个通知组,把同一个邮箱分别放进两个组,再让一个订阅同时命中这两个组。触发一次告警,消息日志里应该只有一条发给该邮箱的任务,而不是两条——这就是 tracker 在起作用。

结语

接收方去重的核心不是"用一个 set 去重复",而是想清楚在哪一层去重、按什么维度去重:组内按人去重、Webhook 按渠道去重、任务按唯一键兜底、时间按指纹抑制。四个维度对应四个不同的问题,混在一起用一个 DISTINCT 是解决不了的。而"去重必须在生成任务之前"这条约束,决定了同一个人只会被通知一次——而不是先发两次再想办法撤回。