最近打开项目的issue列表,看到一条标题简洁到不能再简洁的feature request:“option to combine notifications in 1 email”。提需求的用户没写长篇大论,就一句话:能不能把一段时间内产生的多条通知合并到一封邮件里发,不要一有动静就给我发一封,邮箱直接被刷屏了。
这条issue看起来只是加一个开关的事,实际动手之后才发现,它牵扯出的问题比标题长得多:合并窗口怎么定、按什么维度合并、紧急告警要不要绕过合并、多实例部署下怎么保证不重复发送、邮件模板怎么组织几十条摘要……这篇文章就是对这个需求从提出到上线的一次完整复盘,希望给正在做通知系统、邮件告警、消息推送的同学一些可参考的经验。
1. 需求背景:用户为什么需要“合并通知邮件”
1.1 一条issue背后的真实痛点
先说下我们产品的背景。这是一个面向开发团队的项目协作与监控平台,服务端会针对项目动态、CI构建结果、服务异常等事件给用户发送邮件通知。早期逻辑很简单:事件产生后,异步调用邮件服务,给订阅用户发一封邮件。功能确实能用,但随着接入项目增多、监控项变多,问题开始暴露。
最典型的使用场景是这样的:周五晚上某个服务开始不稳定,触发了告警,五分钟内同一类错误事件产生了三四十条,用户就收到三四十封邮件。手机锁屏上全是一模一样的主题,打开邮箱翻半天才发现最早那条才是真正有用的。这时用户跑到仓库里提issue说想要合并选项,表述是“option to combine notifications in 1 email”,背后的真实需求是:降低邮件噪音,让关键信息在一封邮件里就能看全。
这其实是通知系统发展到一定阶段必然会遇到的问题。单条邮件在事件量少时体验很好,事件量一大,单发模式的边际成本会急剧上升,用户端的“邮件疲劳”也会加深。所以这条feature request不是个例,而是产品演进过程中用户用脚投票出来的结果。
1.2 通知轰炸的代价:不只是“烦”
很多人觉得合并邮件只是为了“少收几封邮件”,体验好一点而已。实际从系统运营角度看,通知轰炸的代价要具体得多:
- 邮件服务成本。邮件发送服务大多是按量计费的,同一个事件重复发几十封,费用直接翻倍。如果业务量大,这部分开销很可观。
- 关键通知的触达率下降。用户被大量低质量通知淹没后,会对所有邮件免疫,真正重要的告警也可能被划走不看,甚至直接屏蔽发件人。
- 投诉与退订风险。部分用户被轰炸烦了,会点击“投诉垃圾邮件”,这会影响发件域名的信誉,导致后续正常邮件也进垃圾箱。这是最麻烦的连锁反应,因为修复域名信誉远比少发几封邮件难。
所以把“合并通知”当成一个正经功能来做,而不是一个顺手加的开关,是有实际价值支撑的。它同时改善用户体验、降低成本、保护发件信誉。
1.3 同类产品怎么处理这类问题
在做方案之前,我习惯先看看成熟产品是怎么处理的。GitHub的通知管理是一个典型参考:它允许用户对仓库的watch级别进行精细控制,可以只看参与讨论、看发布、忽略全部,邮件端也支持按线程聚合;PagerDuty这类告警平台则会把同一事件在某个时间窗口内的多次触发合并成一次incident,对应地只发一轮通知;Grafana的notification policy也支持group_wait、group_interval之类的参数,本质上是把相同标签的告警归到同一组后再发。
这些产品给的启发是一致的:合并通知不能只做一个“是否合并”的开关,至少要解决三个问题——合并的触发条件是什么,合并到什么粒度,哪些通知不能被合并。这三个问题没有标准答案,要结合自己产品的场景定。GitHub聚合的是“线程”(同一讨论串),PagerDuty聚合的是“事件”(同一告警源),我们做的是通用通知服务,所以要设计一个更通用的聚合维度。
2. 方案设计:合并通知的核心决策与取舍
2.1 合并窗口怎么定:时间驱动还是数量驱动
第一个要定的是合并窗口。我们的设计里有两个可配置参数:
- 时间窗口(combine.window):通知进入缓冲后,最长等待多久必须发出。比如设15分钟,那么最多延迟15分钟,用户不会觉得通知“丢失”了。
- 数量阈值(combine.max-batch):缓冲区里某类事件达到多少条时,提前触发发送,不必等满时间窗口。这是为了防止极端情况下缓冲区堆积过大,也是为了让量大的场景下延迟更短。
这两个参数用“谁先到谁触发”的策略,简单说就是时间窗口兜底,数量阈值加速。时间窗口用时长来控制最大延迟,数量阈值用事件量来控制吞吐。
具体参数怎么给默认值?我们拍脑袋定过一版,后来根据实际使用调整了。默认时间窗口是15分钟,因为对大部分协作场景来说,15分钟的延迟几乎无感;默认数量阈值是50条,主要是为了避免一封邮件里塞几百条事件,正文长到没人会读完。这两个值必须做成可配置的,不同团队对延迟和噪音的容忍度完全不同,没有万能值。
2.2 合并维度与分组规则
第二个问题是按什么维度合并。当时我们内部讨论过两个方向:按接收人合并,还是按“项目+接收人”合并。
最简单的方案是按接收人合并:一个用户在窗口内收到的所有通知打包成一封。优点是邮件少;缺点是主题会很混乱,比如一封邮件里既有构建成功、又有服务告警、还有评论回复,信息太杂。
我们最终采用的是“按接收人+按项目分组”的二级结构。简单说:邮件还是按接收人聚合(一个用户一封),但邮件内部按项目分组展示,每个项目下面再按类型列出事件明细。这样邮件数量少的优势保留了,邮件内容的可读性也保住了。如果项目很多、差异很大,也可以用group-by参数切换成只按类型分组,但生产环境我们默认还是项目分组。
当然,这一步也会带来一个需要权衡的问题:缓存key的粒度变细了,缓冲区里的对象会变多,内存损耗会上升。对通知服务来说,单条事件本身很小,内存开销基本可以忽略,不需要过度设计。
2.3 紧急事件的“绕过通道”
合并通知本质上是拿“及时性”换“整洁性”。有些场景不能接受延迟,比如服务宕机、安全告警、账单扣费失败。这类事件如果也被塞进15分钟的合并窗口,用户可能错过了最佳处理时机,那功能就变成事故了。
所以方案里必须有一个绕过机制。我们给事件设计了级别(severity)字段,critical级别的通知默认不进入合并缓冲,直接走即时发送通道;high级别是否走合并由配置决定;normal和low级别一律走合并。这个机制在实现上成本很低,就是在事件入口加一个分支判断,但它的价值很大,直接决定了这个功能能不能在线上环境安全打开。
我们还在配置里加了一个开关,叫critical-immediate,默认true。如果某些团队想把所有通知都合并,可以把它关掉,但我们不会推荐这样做。
2.4 配置项的最终形态
功能最终面向用户的配置项如下表所示。配置不是越多越好,每多一个配置,用户就多一份认知负担,所以能收敛的尽量收敛。
| 配置项 | 默认值 | 说明 |
|---|---|---|
| combine.enabled | false | 是否开启合并通知,默认关闭,保持向后兼容 |
| combine.window | 15m | 合并时间窗口,超过后强制发送 |
| combine.max-batch | 50 | 单组事件数量阈值,达到后提前发送 |
| combine.wait-for-batch | false | 窗口内只有1条事件时是否也延迟合并,true则延迟,false则立即发 |
| combine.critical-immediate | true | critical级别事件是否绕过合并即时发送 |
| combine.group-by | project | 邮件内分组维度:project或type |
wait-for-batch这个配置是后来加上的,因为有个内部团队反馈说,晚上事件少的时候,一封邮件里就一条通知,还要等15分钟才发,体验反而变差。加了之后他们设成false,单条事件立即发,多条才聚合,灵活很多。
3. 实现细节:从事件到摘要邮件的完整链路
3.1 整体模块划分
功能落地时,我们没把逻辑塞进已有的邮件发送模块里,而是独立出了一个“通知聚合器”(NotificationAggregator),放在通知入口和邮件发送器之间。整个链路是:
事件接入接口 -> 聚合器缓冲与聚合 -> Flush触发 -> 摘要渲染 -> 邮件发送器 -> SMTP
这样做的原因很直接:聚合器是可插拔的,如果某天不做合并了,把它摘掉,事件就直接走原逻辑,对主链路影响最小。写代码时,模块边界越清晰,后续维护越省心。
这个聚合器需要解决四件事:接收事件、按key缓存、按条件触发flush、渲染发送。下面分别说。
3.2 聚合器核心实现
聚合器核心结构用的是ConcurrentHashMap,key是聚合维度(接收人ID + 项目ID + 类型等),value是一个PendingGroup对象,里面存事件列表和首次事件进入时间。这样实现简单直接,单机内性能完全够用。
以下是核心逻辑,我用Java表示,逻辑和语言无关,换成Go、Python同理:
public class NotificationAggregator { private final ConcurrentHashMap<AggregateKey, PendingGroup> buffer = new ConcurrentHashMap<>(); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private final Duration window; private final int maxBatch; private final List<FlushListener> listeners = new CopyOnWriteArrayList<>(); public void onEvent(NotificationEvent event) { // 紧急事件直接发,不进入聚合 if (event.getSeverity() == Severity.CRITICAL && criticalImmediate) { sendImmediately(event); return; } AggregateKey key = AggregateKey.from(event, groupBy); PendingGroup group = buffer.computeIfAbsent(key, k -> new PendingGroup(key, Instant.now())); group.add(event); // 数量阈值触发 if (group.size() >= maxBatch) { flush(key); } } @Scheduled(fixedDelayString = "${notify.combine.window}") public void flushExpired() { Instant deadline = Instant.now().minus(window); buffer.forEach((key, group) -> { if (group.getFirstEventTime().isBefore(deadline)) { flush(key); } }); } private void flush(AggregateKey key) { PendingGroup group = buffer.remove(key); if (group == null || group.isEmpty()) { return; } listeners.forEach(l -> l.onFlush(group)); } }这里有一个关键点:flush的时候不是直接发邮件,而是通过FlushListener把数据交给下游,下游做一个统一的摘要渲染和发送。这样聚合逻辑和发送逻辑解耦,也方便测试。
3.3 邮件摘要模板设计
合并邮件和普通单发邮件最大的区别在于邮件正文组织。单发邮件主体就是事件内容;摘要邮件的主体是一个事件列表,必须让用户能在30秒内扫完关键信息。
我们用Freemarker模板渲染摘要邮件。核心是两层循环:外层遍历项目分组,内层遍历事件。每条事件默认展示一个标题行,超长内容会被截断。如果某组事件超过10条,只展示前10条,末尾加一行提示“还有N条事件未展示,请到控制台查看完整列表”,避免邮件过长。
邮件主题也有讲究。单发邮件的主题是“[项目A] 构建失败”,摘要邮件的主题是“[通知摘要] 您有12条未读事件(3个项目)”。之前我们试过把事件主题都拼进邮件标题,结果长到被邮件服务商截断,改成只写数量后干净很多。这个细节要特别注意,主题太长不仅会被截断,还可能触发服务商的垃圾邮件规则。
3.4 分布式环境下的幂等处理
我们的通知服务是多个实例部署的,事件会通过消息队列随机分发到任意实例。如果聚合器各自维护内存buffer,同一个用户的事件可能被分散到两台实例上,各自为政,合并效果就会打折扣。
线上环境如果要求不高,可以采用“按用户哈希路由”的思路:事件进入消息队列时,根据接收人ID哈希,把同一个人的消息路由到固定的实例上。这样单个实例内的buffer就是全局视图,实现成本最低。我们内部量大,所以直接采用了Redis+定时扫描的方案做全局聚合,两个方案谈不上谁绝对好,关键是符合自己的规模。
在这种方案下还引出了幂等性:flush是一个“取出并删除”的操作,多实例并发flush同一个key可能重复发送邮件。我们用的是Redis的SET NX EX锁来保证同一时刻只有一个实例在flush某个key,锁的过期时间设为30秒,正常情况下flush在这个时间内肯定完成。
4. 踩坑实录:上线前后遇到的问题与排查
4.1 时区问题导致摘要时间错乱
第一个吐槽来自内部测试:摘要邮件里的时间比实际时间慢了8小时。排查后发现,事件时间在存储时是UTC,渲染模板时直接用了服务器本地时区,而我们的服务器时区正好有偏移,用户在外区看到的时间自然就是错的。
这个问题的根源在于时间处理不统一。修复方案是在渲染层把时间统一转成接收者的个人时区,用户设置里有时区字段,取不到就用企业默认时区。这里我分享一个经验:任何通知类功能,内部存储一律用UTC,展示时才做时区转换,不能在存储时就把本地时区写进去。
4.2 多实例重复发送
还有一个问题在压测阶段暴露出来:同一批事件偶尔会收到两封内容几乎一样的邮件。原因是消息队列做了重试投递,某条事件在消费端处理超时后重新入队,又被另一个实例消费了一次,聚合器就收到了两份相同事件。
解决办法有两步。第一步,消费端做去重,事件本身带一个全局唯一的eventId,处理前查一下是否已处理,这里我们用了Redis的幂等集合。第二步,聚合器内部对同key事件合并时会根据eventId去重。两层去重下来,重复邮件的概率基本归零。我们其实没有让数据库去做唯一约束,因为通知识别是弱一致场景,偶尔重复可容忍,但容忍不等于不处理。
4.3 邮件体过大被服务商拒绝
摘要邮件的邮件体大小也要重视。刚开始maxBatch设得比较大,有用户在一个窗口内收到几十条带完整错误堆栈的事件,邮件大小超过2MB,被邮件服务商退了回来。
教训是:摘要邮件的正文必须在组装前就限制大小,而不是组装完成后再判断。我们的策略是事件进flusher前先按条数截断,单条事件内容按长度截断,超过的部分提供控制台链接让用户点击查看。还要注意,正文里的HTML标签不能因为截断而破损,否则邮件在部分客户端里会显示错乱。最好在截断时只保留完整段落,不按字节硬切。
4.4 和既有“立即发送”逻辑的兼容
这个功能推出时,有一部分老用户已经习惯了即时通知,如果默认开启合并,他们一定会来反馈“为什么邮件变慢了”。所以我们在配置层面必须保证向后兼容:combine.enabled默认false,未开启的用户走老逻辑,行为完全不变。
用户升级到新版本后,我们不是在发送链路里硬塞合并逻辑,而是在通知设置页面加了一个“通知频率”选项:实时、摘要(15分钟)、每日摘要。这样从产品层面给了用户明确的预期,也把“合并通知”从一个隐藏配置提升成了用户可感知的能力。这一改动比单纯加配置项带来的接受度高很多。
实际运营中,我建议分两步走:第一版先做后台配置,灰度一部分内部团队用;第二版再把功能暴露到用户设置页,配上引导说明。直接全面上线的话,对习惯了实时邮件的用户冲击比较大。
4.5 排查速查表
上线后我们整理了一张排查表,遇到问题先对照着看,能省很多时间。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 合并邮件一直没收到 | combine.enabled未开或窗口未到 | 查聚合器日志,确认flusher是否触发 |
| 只收了几封,数量不对 | maxBatch阈值过早触发 | 看配置,调大阈值 |
| 邮件内容重复 | 消息队列重试导致重复事件 | 查消费去重逻辑和eventId |
| 摘要时间显示错误 | 时区未转换 | 检查渲染层时区处理 |
| 邮件被退信/进垃圾箱 | 邮件体过大或主题过长 | 检查邮件大小、主题长度和域名信誉 |
| 紧急告警也被合并了 | critical-immediate配置被关闭 | 确认系统级别配置,建议保持默认true |
5. 上线效果与下一步规划
5.1 内测数据:邮件量下降明显
功能灰度两周,我们统计了内部和种子用户的数据。开启合并通知的项目,邮件发送总量下降了约68%,同一个用户每天收到的通知邮件中位数从9封降到了3封。打开率提升了约15个百分点,说明邮件少的时候用户更愿意细看。邮件服务商的账单也明显下降,虽然这部分不是核心目的,但确实是实打实的收益。
有个细节值得说:合并之后,退订和垃圾投诉的点击率也下降了。虽然样本不大,但符合预期——邮件噪音减少后,用户不会那么烦躁地去找退订入口。
5.2 用户反馈与迭代
收集到的用户反馈里,正面反馈占多数,集中在“早上打开邮箱终于不是十几封了”这类声音。也有几条有建设性的负面反馈:有人希望不同的项目用不同的合并窗口,有人希望摘要邮件里可以直接点按钮处理事件(比如确认告警、标记已读),还有人希望按小时维度做“今日摘要”而不是15分钟窗口。
这些反馈我们排进了后续迭代。第一轮迭代做了“通知频率”三级选项(实时、摘要、每日),用户可以在设置页自由切换;第二轮计划做摘要邮件内嵌操作按钮,比如“确认告警”“跳转工单”,把邮件从信息展示升级成处理入口。这一步比单纯合并邮件价值更大,也是业内比较认可的方向。
5.3 后续可以扩展的方向
这个功能做完以后,我回头看,觉得合并通知本质上是一个“通知降噪”体系的一部分,后续还值得做的方向至少有三个:
第一是智能优先级。结合用户行为,判断哪些事件用户通常会点开,哪些事件看了标题就够了,对后者自动降噪。第二是跨渠道整合,邮件、站内信、IM机器人共用同一套聚合策略,用户在IM里收到的也是合并后的摘要,而不是每个渠道各发各的。第三是通知回执闭环,记录用户是否打开摘要、是否点击了里面的链接,用这些数据反向调整合并策略。
这三个方向工程量都不小,但方向是对的。通知系统做久了就会发现,用户要的从来不是“更多通知”,而是“更少但更有用的通知”。合并邮件只是这个目标的第一块拼图。
写到这里,我在这个功能上踩过的坑、做过的取舍基本都交代完了。如果让我只说一条最值得分享的经验,那就是:接到类似“option to combine notifications in 1 email”这种看起来很小的feature request,不要急着在邮件发送前面加一个开关就完事,先花时间把合并窗口、合并维度、紧急绕过、幂等控制这几个问题想清楚。这些决策直接决定了功能是让用户觉得“清爽了”,还是变成“邮件来得更慢但还是一样乱”。希望这篇复盘对正在做通知系统的你有帮助。