通知邮件合并方案:滑窗聚合实现用户通知降噪

通知邮件合并方案:滑窗聚合实现用户通知降噪 1. 需求拆解为什么“通知合并在1封邮件里”是个高频请求先聊个实际的场景。你负责的某个系统无论是自研的工单平台、监控告警系统、还是SaaS产品的用户操作通知只要用户量一上来通知邮件就会变成一种骚扰。我见过最夸张的情况是一个用户一晚上收到三十几封系统邮件有人他、有人评论了他的工单、他负责的服务触发了告警、还有一条密码即将过期的提醒。第二天早上他打开邮箱直接把这堆邮件当垃圾处理了连真正需要处理的那条漏掉都没看见。这个Feature request的核心诉求说白了就一句话别给用户发一堆邮件把一段时间内的通知攒成一条发过去。从用户侧来看这是“通知疲劳”的典型解法。从系统侧来看这其实牵扯到邮件发送成本、通道信誉、甚至SPF/DKIM的校验成功率。因为邮件服务商比如腾讯企业邮、阿里云邮件推送、AWS SES对单IP/单域名的发信频率都有隐性限制一次性发100封小邮件和发10封包含20条通知的聚合邮件后者对通道的压力小得多退信率也更低。这个需求之所以被反复提出来还有一个深层原因用户不是不想看通知而是不想看二十封只有一句话的邮件。一封摘要邮件把重要的事放前面把次要的折叠起来反而更可能被点开。所以这个需求表面是“合并通知”本质是“通知的优先级排序和体验优化”。我在实际做这块时总结了三个核心问题聚合的窗口期怎么定。定太短聚合效果差用户还是被频繁打扰定太长通知的实时性又没了。哪些通知适合合并哪些不适合。像密码重置、封号通知这种必须立即单独发普通评论、点赞、低等级告警则可以进聚合池。聚合邮件的展示结构。总不能就是把这堆通知原文拼在一起得有一个清晰的摘要让用户一眼看出哪条重要。把这三个问题想清楚再动手写代码就顺了。2. 方案选型三种主流实现路线与取舍我见过各种团队实现“通知合并”的方式基本逃不出下面三种。没有绝对最好的方案只有适不适合你当前系统状况的取舍。2.1 定时批量轮询方案这应该是最容易想到、也最容易落地的方案。系统默认每5分钟或者1分钟、10分钟取决于你对实时性的要求跑一个定时任务把这段时间内产生的通知从数据库里捞出来按用户分组然后每组发一封邮件。-- 伪代码逻辑 SELECT user_id, group_concat(notification_id) FROM notifications WHERE status pending AND created_at NOW() - INTERVAL 5 minutes GROUP BY user_id;优点是逻辑非常直白对现有代码侵入性小。缺点是实时性差如果用户恰好在你发完邮件后1秒产生了新通知那他要等下一个周期才有邮件。另外如果聚合窗口内的通知量特别大一次性查出来再分组对数据库会有压力。2.2 事件驱动 延迟队列方案这种方式更适合对实时性有要求的团队。每当有通知产生不立即发送而是丢进一个延迟队列比如RabbitMQ的延迟插件、Redis的ZSet、或者Java的DelayQueue延迟时间就是聚合窗口的长度。比如设置延迟5分钟用户A在10:00产生一条通知这条通知进入队列预定在10:05被消费。在10:03时用户A又产生了一条通知也进入队列预定10:08被消费。那到了10:05第一条通知被弹出时系统要检查一下用户A还有没有别的待聚合通知如果有就一起带上如果没有就单独发。到了10:08第二条通知再走同样的流程。这个方案的好处是实时性和聚合效果相对均衡消息产生后最多延迟一个窗口就能收到邮件。但缺点是队列维护有成本而且“检查是否还有待聚合通知”这一步如果处理不好容易产生并发问题——两条通知几乎同时被消费到各自都以为自己没有兄弟通知结果还是发了两封邮件。2.3 滑窗聚合方案这是我在实际项目中用得最多、也最推荐中小团队采用的方案。核心思路是维护一个“通知聚合窗口”窗口内到达的通知不断累加窗口快结束时才统一发送。这个窗口可以用Redis实现也可以用一个数据库表加状态字段实现不需要额外引入消息队列组件。具体做法是每当有通知进入系统先查Redis里有没有当前用户对应的聚合缓存key如果有把通知内容追加进去把过期时间续上如果没有说明是窗口内第一条通知创建缓存key并设置过期时间为窗口长度比如10分钟同时创建一个“待聚合记录”。等到key到期后后台销毁key并从“待聚合记录”里取出通知封装成邮件发送。采用Redis的过期回调作为发送触发点有一个坑Redis的过期事件不是实时的默认情况下key过期后事件才会被推送给订阅方这个延迟有时候会到秒级甚至分钟级。所以更稳妥的做法是不依赖过期回调而是用一个低频率的定时任务比如每分钟跑一次扫描待聚合记录检查缓存key是否已过期过期了就发送。这三种方案我都踩过坑总结一下适用场景方案实时性实现成本系统侵入度适用场景定时批量低分钟级极低低通知量不大、用户可接受延迟的内部系统延迟队列高秒级中高高用户量大、对实时性有要求的SaaS产品滑窗聚合中分钟级中中通用场景兼顾成本与体验的折中方案如果你问我个人建议新项目或者改造老项目优先考虑滑窗聚合。定时批量方案的体验确实太差用户投诉率高延迟队列方案对运维要求高团队不熟的话容易在队列堆积时出大事故。滑窗聚合基本上是个“投入产出比”最高的选择。3. 核心实现细节滑窗聚合方案完整实操这一节我会把滑窗聚合方案从数据库设计到代码落地完整走一遍。我以Java Redis MySQL为例但这个思路可以平滑迁移到任何一种语言或存储组合。3.1 数据库表设计首先需要一张通知主表以及一张聚合记录表。主表还是存原始的每一条通知聚合记录表则记录“哪些通知合并到了哪封邮件”。CREATE TABLE notification ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, content TEXT NOT NULL, type VARCHAR(32) NOT NULL, -- 通知类型如 COMMENT、AT_REMIND、ALERT priority TINYINT NOT NULL DEFAULT 1, -- 优先级0高 1普通 2低 status TINYINT NOT NULL DEFAULT 0, -- 0待聚合 1已聚合 2已发送 3已取消 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, sent_at DATETIME NULL, INDEX idx_user_status (user_id, status), INDEX idx_created (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE notification_agg ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, notification_ids TEXT NOT NULL, -- 由哪些通知ID组成逗号分隔 send_key VARCHAR(64) NOT NULL, -- Redis中的聚合窗口key status TINYINT NOT NULL DEFAULT 0, -- 0待发送 1已发送 scheduled_send_time DATETIME NOT NULL, actually_sent_time DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_send_key (send_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个设计的要点在于notification_agg表其实是“聚合邮件的一句摘要”它不需要存完整的邮件内容邮件内容通过notification_ids去关联查询。这样设计的好处是如果后期要重发、补发或者用户点击“查看全部通知”跳到站内信列表都不用再从邮件里解析直接从库里查就行。3.2 核心流程通知进入聚合窗口public void onNotificationCreated(Notification notification) { if (!shouldAggregate(notification)) { // 不聚合的通知密码重置、封号、风控提醒等立即发送 sendSingleNotification(notification); return; } String redisKey agg: notification.getUserType() : notification.getUserId(); // 使用Redis的原子自增来判断是不是窗口内的第一条 Long count redisTemplate.opsForHash().increment(redisKey, count, 1); if (count 1L) { // 这是窗口内第一条通知创建聚合窗口记录待发送任务 redisTemplate.expire(redisKey, AGGREGATE_WINDOW_SECONDS, TimeUnit.SECONDS); // 写入聚合记录表 NotificationAgg agg new NotificationAgg(); agg.setUserId(notification.getUserId()); agg.setSendKey(redisKey); agg.setScheduledSendTime(new Date(System.currentTimeMillis() AGGREGATE_WINDOW_SECONDS * 1000)); notificationAggMapper.insert(agg); // 更新主表状态 notificationMapper.updateStatus(notification.getId(), 1); } else { // 窗口内已有通知直接追加 notificationMapper.updateStatus(notification.getId(), 1); aggMapper.appendNotificationIdBySendKey(redisKey, notification.getId()); // 这里有一个细节每次追加时都续期一次 redisTemplate.expire(redisKey, AGGREGATE_WINDOW_SECONDS, TimeUnit.SECONDS); } }这里有个很关键的细节是我踩坑踩出来的每次追加通知时要不要刷新过期时间取决于你对“聚合窗口”的定义。如果你希望窗口是固定的比如每10分钟聚合一批那第一次设置过期时间后就不应该续期。如果你希望窗口是滑动的比如距离用户上一条通知10分钟内新通知都会合并进去那每来一条新通知就应该续期。我做的项目通常选择续期方案因为用户往往是集中在一段时间内活跃的比如早上9点到9点10分连续操作了5次5条通知合并成一封的效果最好。如果固定窗口恰好把操作切断了用户体验就分裂了。3.3 发送逻辑扫描过期聚合窗口定时任务每分钟执行一次扫描所有待发送的聚合记录判断Redis key是否已过期Scheduled(cron 0 * * * * ?) // 每分钟跑一次 public void scanAndSend() { ListNotificationAgg pendingList notificationAggMapper.findPendingBefore(new Date()); for (NotificationAgg agg : pendingList) { // 如果Redis中key已不存在说明窗口已关闭可以发送 Boolean exists redisTemplate.hasKey(agg.getSendKey()); if (exists ! null !exists) { sendAggregatedEmail(agg); } } }sendAggregatedEmail做的就是几件事根据notification_ids查通知明细按优先级排序生成摘要邮件内容发送更新状态。这里要注意使用Redis key是否存在来判断窗口是否关闭存在一定的竞态条件风险。如果一个通知恰好在你查询完key、还没发送的间隙进来那这条通知就会落到下一个聚合窗口而当前这封邮件已经发出去了。用户看到的效果就是收到了这封邮件后又收到了下一封包含那一条迟来通知的邮件。这个瑕疵通常是可以接受的因为用户无感知只要不是经常发生。要彻底解决需要加分布式锁但复杂度会上升不少我一般不建议中小团队为了这个临界场景引入锁机制。3.4 邮件模板设计一封聚合邮件的自我修养聚合邮件的内容结构很大程度上决定了这个功能上线后是加分还是减分。我见过很多团队的功能做的没问题结果邮件模板设计得一塌糊涂要么把所有通知原文堆在一起要么摘要写得过于简略用户都看不懂。我的经验是三层结构第一层顶部放高优先级通知的摘要。比如“你有2条需要立即处理的告警”每条告警给出简短的说明文字让用户不用点开邮件就能判断是否要处理。第二层中间列出普通通知的分类摘要。“12条新评论”“3个人了你”每一类给个链接点击跳到站内对应位置。第三层底部放一个“24小时内全部通知”的汇总入口方便强迫症用户查看有没有漏掉的信息。模板的body部分用HTML编写注意针对Outlook、Gmail、企业邮箱的不同渲染兼容性。我踩过的坑是Outlook对margin和padding的支持很差必须用table布局而不是div布局否则在Outlook里邮件会变形。另外Gmail客户端会裁剪过长的HTML源码所以模板一定要精简能用纯文本表达的摘要就别堆HTML。一个我常用的简洁模板结构table width100% cellpadding0 cellspacing0 border0 trtd stylepadding: 20px; h2你的通知摘要/h2 !-- 高优先级区域 -- h3需要处理/h3 ul.../ul !-- 普通通知区域 -- h3新动态/h3 p你有12条新评论点击查看/p !-- 汇总入口 -- a hrefhttps://yourapp.com/notifications查看全部通知/a /td/tr /table4. 哪些通知必须特殊处理不聚合的优先级规则聚合虽好但不是所有通知都适合聚合。这块如果做不好很容易引发事故级别的用户投诉。我整理了一个“必须立即发送”的清单你可以对照自己的业务类型调整账密安全类密码重置、登录验证码、异地登录提醒、账号被锁定。这类通知的时效性极强而且用户是在主动等待延迟5分钟都会让用户以为系统坏了。资金操作类支付确认、退款到账、扣款失败。跟钱相关的一秒钟都不能糊弄必须单独发。高风险告警服务宕机、数据异常、安全事件。比如你的监控系统发现线上服务挂了这种告警应该立即通过短信、电话、IM工具同步触达不能跟普通通知混在邮件里。强制合规类隐私政策变更、服务条款变更、有法律效力的通知。这些通常有送达时间要求不能放在聚合邮件里让用户错过。实现上我的做法是在notification表加一个type字段然后在shouldAggregate方法里白名单/黑名单控制private boolean shouldAggregate(Notification notification) { // 这些类型绝不聚合 SetString nonAggregatableTypes Set.of( PASSWORD_RESET, LOGIN_CODE, ACCOUNT_LOCKED, PAYMENT_RECEIPT, PAYMENT_FAILED, SERVICE_DOWN, SECURITY_ALERT ); return !nonAggregatableTypes.contains(notification.getType()); }另外还要考虑一个细节用户是否打开了“合并通知”的开关。既然这个功能以Feature request的形式被提出来说明有一部分用户希望合并但也一定有一部分用户习惯了每条通知都即时收到。所以做这个功能时一定要在用户的通知偏好设置里提供一个开关默认是合并但允许用户选择“全部即时接收”。否则上线后你会收到大量“我怎么收不到通知了”的工单。5. 上线运营配置参数与灰度策略功能做完了怎么平稳上线也是门学问。我建议分三步走。5.1 灰度发布第一周只对10%的用户启用聚合功能观察投诉率和邮件打开率。第二周扩大到50%第三周全量。灰度比例可以通过配置中心动态调整不要把它写死在代码里。我在灰度期间遇到过一件有意思的事某团队上线聚合通知后某个客户发来工单说“你们的通知系统是不是挂了我一天没收到任何邮件”。排查后发现该用户是邮箱服务商的VIP发来的每一封邮件都被单独处理了但聚合邮件因为包含的链接过多被对方邮件网关判定为垃圾邮件直接进了垃圾箱。这个案例说明邮件网关对聚合邮件更容易产生误判因为内容更复杂、链接更多、HTML占比高。所以灰度期间要特别留意垃圾箱率这个指标。5.2 参数调优聚合窗口的默认值我推荐从5分钟起步。太短1分钟效果不明显太长30分钟用户会觉得通知不实时。窗口内通知条数的上限也需要设一个阈值。比如一个用户在5分钟内产生了100条通知合并成一封邮件也还是很吓人的。我的经验是超过10条的聚合邮件只发送前10条摘要其余提示“还有90条通知点击查看”。这样邮件的体积可控也降低了被网关拦截的概率。另外优先级在聚合邮件里的展示权重实际操作中要反复调。我给过一个“优先级 通知类型基础分 用户活跃度加成 时间衰减”的简单公式weight base_score(type) active_bonus(user) * decay_factor(age)base_score每个类型不一样告警50分、提醒30分、评论10分active_bonus是用户在最近1小时内的操作次数乘以一个系数decay_factor是时间衰减通知产生2小时以上权重减半。聚合邮件里对所有通知按weight排序取前N条展示重点。这个公式不需要很精确但能明显提升摘要邮件的“相关性”让用户觉得这不是一封垃圾汇总而是真正帮他筛选过的信息。5.3 监控与告警上线聚合通知功能等于是在原有的通知发送链路上加了一个中转层需要一个专门的监控大盘。我建议至少监控这几个指标聚合窗口内通知的平均条数低于1.5说明聚合效果差窗口设置可能太短。聚合邮件的打开率对比非聚合邮件的打开率如果明显更低说明摘要设计有问题。垃圾箱率这个比较高的话马上检查模板和链接数量。聚合发送延迟从第一条通知进入窗口到聚合邮件发送成功的P95延迟超过了设定值要告警。聚合记录表里“窗口内只聚合单条通知”的比例这个比例如果超过40%说明大部分通知都是孤立的聚合意义不大可以考虑缩短窗口。6. 常见问题与踩坑实录我从自己做过的几个聚合通知项目中把高频问题整理成了一份速查表基本覆盖了你会遇到的80%的问题。6.1 为什么用户还是收到了多条邮件一般有三个原因。第一某些通知类型没有加入聚合白名单检查shouldAggregate的规则。第二用户的聚合开关没有生效可能是缓存或配置下发延迟先刷新配置再确认。第三跨域用户的聚合窗口key设计不合理比如一个用户在一个系统里同时用了App端和Web端两端各自维护了独立的Redis key那同一用户就会同时出现在两个聚合窗口里。解决办法是把userType也纳入key并且确保同一用户所有入口都使用同一个标识。6.2 聚合邮件被判定为垃圾邮件怎么办这个问题我在灰度时撞上过。几个常见的诱因邮件里链接太多、HTML/纯文本比例失衡、主题行里的模板关键词被网关照单了。解决办法是邮件内嵌链接使用统一的短链接服务把链接触达次数降到15个以内调整HTML和纯文本的比例至少保证纯文本版本存在主题行避免使用“Alert”“Notification”等高频垃圾词可以用产品名代替。我实测过把主题从“通知摘要”改成“你的项目动态”后打开率提升了将近20%垃圾箱率也明显下降。6.3 聚合窗口内新通知一直在来邮件永远发不出去如果你采用“滑动窗口每次续期”的方案理论上一个高频用户的通知可能永远处于续期状态邮件迟迟不触发。这个问题的解法是加一个“最大聚合跨度”限制无论用户怎么活跃聚合窗口从创建开始最多只能持续30分钟到点必须发送。实现上使用Redis key的过期时间不刷新另用一个固定key记录窗口的创建时间每次续期前先判断总时长是否超过上限。6.4 数据库和Redis的数据不一致怎么办聚合窗口机制下Redis的key是瞬时的数据库的聚合记录是持久的。如果某次发送失败数据库里的聚合记录状态没有更新定时任务下次还会捞出来重发导致用户收到重复邮件。我建议引入一个“发送中”状态定时任务扫描时先把待发送记录的状态从待发送置为发送中加乐观锁发送完成后更新为已发送发送失败则回退为待发送并记录错误信息。如果两次发送都失败就进入人工告警列表。6.5 通知量大了之后聚合查询会不会拖垮数据库聚合邮件生成时需要根据notification_ids查询通知明细。如果这个ID列表很长比如几百个用IN查询效率会很低。我的做法是在聚合记录表增加一个summary字段在聚合过程中每合并一条通知就同步更新summary把通知的核心内容类型、标题、关键摘要拼进去。这样发送邮件时直接读summary字段不需要回查主表。主表仍然保留完整数据用于站内信展示不参与邮件生成。7. 从Feature request到通用经验通知系统的进阶思考做完这个“合并通知”功能我对通知系统的理解又深了一层。以前觉得通知就是“有事件就触发发送”现在越来越觉得通知本质上是一个信息筛选和路由系统把正确的事件以正确的方式在正确的时间送到正确的人手里。合并只是其中一环上游还有去重、分级、限流下游还有退订管理、送达回执、用户偏好。说几个我做完之后回头看才明白的点。第一个是不要试图用一个方案通吃所有场景。即时通知、摘要通知、紧急通知它们的实现路径天然不同。强行让所有通知走同一条链路最后一定是两头不讨好。我在系统里明确区分了三条通道即时通道短信/推送、邮件摘要通道聚合邮件、站内信通道历史记录。各走各的路互不交叉反而更容易维护。第二个是通知频率和用户流失率的关系比想象中更紧密。我调过一组数据某产品对高频操作用户开启聚合通知后两周内的次日留存提升了4个百分点。也就是说减少打扰这件事直接转化为用户黏性。很多时候产品经理纠结的“用户怎么不打开邮件”根源往往不是内容不够吸引人而是邮件本身出现得太频繁用户已经形成了“不看”的条件反射。第三个是一定要给用户控制权。有没有聚合开关、聚合窗口能不能按用户等级调整、重要度阈值能不能自定义这些能力在功能上线后会被大量用到。做功能的时候多做一步后面能少接无数个用户反馈工单。如果你也在规划类似的功能我的建议是先别急着写代码把通知类型梳理一遍把用户投诉案例翻一遍想清楚“哪些必须即时发、哪些可以等一等”再动手。后续做完聚合还可以顺势把这些经验沉淀成一套通知发送规则引擎让业务方可以通过后台配置搞定通知策略而不是每次都要发版。这算是我做这个需求绕了远路之后最想告诉后来人的一句话。