
最近打开项目的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.enabledfalse是否开启合并通知默认关闭保持向后兼容combine.window15m合并时间窗口超过后强制发送combine.max-batch50单组事件数量阈值达到后提前发送combine.wait-for-batchfalse窗口内只有1条事件时是否也延迟合并true则延迟false则立即发combine.critical-immediatetruecritical级别事件是否绕过合并即时发送combine.group-byproject邮件内分组维度project或typewait-for-batch这个配置是后来加上的因为有个内部团队反馈说晚上事件少的时候一封邮件里就一条通知还要等15分钟才发体验反而变差。加了之后他们设成false单条事件立即发多条才聚合灵活很多。3. 实现细节从事件到摘要邮件的完整链路3.1 整体模块划分功能落地时我们没把逻辑塞进已有的邮件发送模块里而是独立出了一个“通知聚合器”NotificationAggregator放在通知入口和邮件发送器之间。整个链路是事件接入接口 - 聚合器缓冲与聚合 - Flush触发 - 摘要渲染 - 邮件发送器 - SMTP这样做的原因很直接聚合器是可插拔的如果某天不做合并了把它摘掉事件就直接走原逻辑对主链路影响最小。写代码时模块边界越清晰后续维护越省心。这个聚合器需要解决四件事接收事件、按key缓存、按条件触发flush、渲染发送。下面分别说。3.2 聚合器核心实现聚合器核心结构用的是ConcurrentHashMapkey是聚合维度接收人ID 项目ID 类型等value是一个PendingGroup对象里面存事件列表和首次事件进入时间。这样实现简单直接单机内性能完全够用。以下是核心逻辑我用Java表示逻辑和语言无关换成Go、Python同理public class NotificationAggregator { private final ConcurrentHashMapAggregateKey, PendingGroup buffer new ConcurrentHashMap(); private final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); private final Duration window; private final int maxBatch; private final ListFlushListener 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配置被关闭确认系统级别配置建议保持默认true5. 上线效果与下一步规划5.1 内测数据邮件量下降明显功能灰度两周我们统计了内部和种子用户的数据。开启合并通知的项目邮件发送总量下降了约68%同一个用户每天收到的通知邮件中位数从9封降到了3封。打开率提升了约15个百分点说明邮件少的时候用户更愿意细看。邮件服务商的账单也明显下降虽然这部分不是核心目的但确实是实打实的收益。有个细节值得说合并之后退订和垃圾投诉的点击率也下降了。虽然样本不大但符合预期——邮件噪音减少后用户不会那么烦躁地去找退订入口。5.2 用户反馈与迭代收集到的用户反馈里正面反馈占多数集中在“早上打开邮箱终于不是十几封了”这类声音。也有几条有建设性的负面反馈有人希望不同的项目用不同的合并窗口有人希望摘要邮件里可以直接点按钮处理事件比如确认告警、标记已读还有人希望按小时维度做“今日摘要”而不是15分钟窗口。这些反馈我们排进了后续迭代。第一轮迭代做了“通知频率”三级选项实时、摘要、每日用户可以在设置页自由切换第二轮计划做摘要邮件内嵌操作按钮比如“确认告警”“跳转工单”把邮件从信息展示升级成处理入口。这一步比单纯合并邮件价值更大也是业内比较认可的方向。5.3 后续可以扩展的方向这个功能做完以后我回头看觉得合并通知本质上是一个“通知降噪”体系的一部分后续还值得做的方向至少有三个第一是智能优先级。结合用户行为判断哪些事件用户通常会点开哪些事件看了标题就够了对后者自动降噪。第二是跨渠道整合邮件、站内信、IM机器人共用同一套聚合策略用户在IM里收到的也是合并后的摘要而不是每个渠道各发各的。第三是通知回执闭环记录用户是否打开摘要、是否点击了里面的链接用这些数据反向调整合并策略。这三个方向工程量都不小但方向是对的。通知系统做久了就会发现用户要的从来不是“更多通知”而是“更少但更有用的通知”。合并邮件只是这个目标的第一块拼图。写到这里我在这个功能上踩过的坑、做过的取舍基本都交代完了。如果让我只说一条最值得分享的经验那就是接到类似“option to combine notifications in 1 email”这种看起来很小的feature request不要急着在邮件发送前面加一个开关就完事先花时间把合并窗口、合并维度、紧急绕过、幂等控制这几个问题想清楚。这些决策直接决定了功能是让用户觉得“清爽了”还是变成“邮件来得更慢但还是一样乱”。希望这篇复盘对正在做通知系统的你有帮助。