企业系统里的消息通知,很容易从“提醒用户及时处理”变成“到处弹、到处发、没人看”。站内信、邮件、短信、企业微信、钉钉、App 推送都能发,但真正难的是:什么事件值得通知,通知谁,用哪个渠道,失败要不要重试,用户能不能退订,业务动作是否会被通知失败拖垮。
通知系统不是把消息发出去这么简单。它连接业务事件、用户偏好、渠道能力、成本控制和审计追踪。设计得好,通知能帮用户减少遗漏;设计得差,通知会变成新的噪音和投诉来源。
本文用一个常见场景做示例:维修工单关闭后,需要通知申请人和资产管理员。申请人只需要站内信和邮件,资产管理员需要站内信;如果邮件失败,不应该影响工单关闭;如果同一工单被重复关闭请求触发,也不能反复轰炸用户。
这个例子不局限于维修工单。审批待办、合同到期、库存预警、付款成功、导入失败、客户跟进超时,都可以用同一套通知设计方法。差别只是事件类型、接收人规则、渠道优先级和频控策略不同。
示例环境:Java 17、Spring Boot 风格服务层、MySQL 8.x。本文会给出表结构、轻量代码骨架、测试思路和 SQL 验证,但重点放在通知策略本身,不会把篇幅主要花在代码堆叠上。
目录
- 通知系统先回答六个问题
- 输入案例:维修工单关闭通知
- 渠道选择:站内信、邮件和短信各管什么
- MySQL表结构:事件、收件人和发送记录
- 实现骨架:业务写outbox,发送异步执行
- 测试重点:少测发送SDK,多测策略边界
- SQL验证:上线后查通知有没有乱发
- 异常边界和上线验收
- 小结和延伸阅读
一、通知系统先回答六个问题
设计通知之前,先不要急着接短信平台或邮件服务器。更重要的是回答六个问题。
| 问题 | 设计含义 | 没想清楚的后果 |
|---|---|---|
| 什么事件要通知 | 只选用户需要及时知道的业务变化 | 系统到处发消息,用户屏蔽所有通知 |
| 通知谁 | 根据业务关系和角色计算接收人 | 漏发关键人,或把内部信息发给无关人 |
| 用什么渠道 | 站内信、邮件、短信各有边界 | 低价值消息走高成本渠道 |
| 什么时候发 | 实时、延迟、汇总、工作时间发送 | 半夜短信扰民,或提醒太晚失效 |
| 失败怎么办 | 重试、降级、人工查看 | 通知失败拖垮主业务,或失败无人发现 |
| 能否退订和频控 | 尊重用户偏好和成本限制 | 同一事件反复提醒,用户反感 |
这六个问题比代码更重要。很多系统通知混乱,不是因为发送接口难写,而是因为事件和渠道没有边界。比如“审批待办”可能要实时站内信,“合同明天到期”可以每天早上汇总邮件,“验证码”才适合短信。
图1:通知设计要先明确事件、收件人、渠道、时机、失败处理和频控。
二、输入案例:维修工单关闭通知
本文固定一组输入,后面的数据模型、代码骨架和 SQL 验证都围绕它展开。
业务对象:维修工单 WO-20260924-001 事件类型:WORK_ORDER_CLOSED 事件标题:维修工单已关闭 触发动作:维修人员确认处理完成,工单状态从 PROCESSING 变为 CLOSED 申请人:user_id = 1008 资产管理员:user_id = 2001 通知策略: 申请人:站内信 + 邮件 资产管理员:站内信 短信:不发送 幂等键:WORK_ORDER_CLOSED:WO-20260924-001 失败策略:邮件失败最多重试3次,不影响工单关闭 频控策略:同一工单关闭事件只通知一次这个案例里有几个关键点。
第一,通知来自业务事件,而不是页面按钮。工单真正关闭后才写通知事件;如果关闭失败,不应该提前通知。
第二,接收人来自业务关系。申请人关心处理结果,资产管理员关心台账状态;其他人没有必要收到。
第三,不同渠道承担不同作用。站内信作为系统内留痕,邮件作为离线提醒,短信成本高且打扰强,本案例不需要使用。
第四,通知失败不应该让业务回滚。工单关闭是主业务,邮件只是提醒。正确做法是业务事务里写 outbox,发送动作异步执行。
三、渠道选择:站内信、邮件和短信各管什么
通知渠道不是越多越好。一个事件同时发站内信、邮件、短信、企业微信,看起来很重视,实际可能是在消耗用户耐心。
站内信适合做系统内留痕。它成本低、可查询、可标记已读,适合待办、处理结果、导入完成、审批结果等大多数业务通知。缺点是用户不登录系统就看不到。
邮件适合做离线提醒和正式通知。它适合日报、周报、到期提醒、处理结果、外部协作信息。缺点是到达不稳定,容易进垃圾箱,不适合做强实时保证。
短信适合做高优先级、短内容、强触达场景,例如验证码、紧急告警、重要审批超时。短信有成本,也容易打扰用户,不应该用来发送普通状态变化。
企业微信、钉钉、App 推送适合组织内部即时提醒,但也要遵守频控和免打扰策略。不要因为接入方便就把所有站内消息同步到即时通讯工具。
可以用一个简单分级来控制渠道:
| 消息等级 | 示例 | 推荐渠道 |
|---|---|---|
| 普通留痕 | 工单关闭、导入完成 | 站内信 |
| 需要离线提醒 | 合同到期、审批待办 | 站内信 + 邮件/企业微信 |
| 紧急且高价值 | 生产告警、支付异常 | 站内信 + 短信/电话 |
| 汇总类 | 每日待办、周报 | 邮件或站内汇总 |
图2:站内信、邮件、短信和即时通讯不要承担同一种通知任务。
四、MySQL表结构:事件、收件人和发送记录
通知表不要只存“标题、内容、是否已读”。一个可靠通知系统至少要区分事件、收件人、渠道发送记录。
CREATETABLEmsg_event_outbox(idBIGINTPRIMARYKEYAUTO_INCREMENT,company_idBIGINTNOTNULL,event_typeVARCHAR(80)NOTNULL,biz_typeVARCHAR(64)NOTNULL,biz_idBIGINTNOTNULL,biz_noVARCHAR(64)NOTNULL,event_keyVARCHAR(120)NOTNULL,titleVARCHAR(200)NOTNULL,contentTEXTNOTNULL,payload_json JSONNULL,statusVARCHAR(32)NOTNULL,next_retry_timeDATETIMENULL,retry_countINTNOTNULLDEFAULT0,create_timeDATETIMENOTNULL,update_timeDATETIMENULL,UNIQUEKEYuk_msg_event_key(event_key),KEYidx_msg_event_status(status,next_retry_time));CREATETABLEmsg_recipient(idBIGINTPRIMARYKEYAUTO_INCREMENT,event_idBIGINTNOTNULL,user_idBIGINTNOTNULL,recipient_roleVARCHAR(64)NOTNULL,channelsVARCHAR(100)NOTNULL,read_timeDATETIMENULL,create_timeDATETIMENOTNULL,UNIQUEKEYuk_msg_recipient(event_id,user_id),KEYidx_msg_user_unread(user_id,read_time));CREATETABLEmsg_delivery_log(idBIGINTPRIMARYKEYAUTO_INCREMENT,event_idBIGINTNOTNULL,recipient_idBIGINTNOTNULL,user_idBIGINTNOTNULL,channelVARCHAR(32)NOTNULL,statusVARCHAR(32)NOTNULL,provider_msg_idVARCHAR(120)NULL,error_codeVARCHAR(80)NULL,error_messageVARCHAR(500)NULL,send_timeDATETIMENULL,create_timeDATETIMENOTNULL,update_timeDATETIMENULL,UNIQUEKEYuk_msg_delivery_once(event_id,user_id,channel),KEYidx_msg_delivery_status(status,create_time));这三张表分工不同。
msg_event_outbox表示一次业务事件。它解决的是“有没有一件事需要通知”。event_key是幂等键,同一个工单关闭事件只能写一次。
msg_recipient表示这次事件要通知哪些人。一个事件可以有多个收件人,每个收件人可以有不同渠道。
msg_delivery_log表示某个收件人在某个渠道上的发送状态。邮件失败、短信失败、站内信成功,要分开记录,后续才能重试和排查。
图3:通知事件、收件人和渠道发送记录拆开后,才能做幂等、重试和审计。
五、实现骨架:业务写outbox,发送异步执行
通知不要在主业务事务里直接调用 SMTP、短信网关或第三方 API。更稳的方式是:业务成功时写 outbox;后台 worker 扫描待发送事件;发送失败后按策略重试。
业务侧只做一件事:在业务状态变更成功时写事件。
@Transactional(rollbackFor=Exception.class)publicvoidcloseWorkOrder(CloseCommandcommand){WorkOrderorder=workOrderRepository.lockById(command.workOrderId());if(!"PROCESSING".equals(order.status())){thrownewServiceException("当前工单不能关闭:"+order.status());}workOrderRepository.close(order.id(),command.operatorId(),command.occurredAt());notificationOutboxRepository.insertIgnore(newNotificationEvent(order.companyId(),"WORK_ORDER_CLOSED","WORK_ORDER",order.id(),order.workOrderNo(),"WORK_ORDER_CLOSED:"+order.workOrderNo(),"维修工单已关闭","你的维修工单已处理完成,请查看处理结果。",command.occurredAt()));}发送侧负责策略:算收件人、算渠道、生成发送记录、调用具体渠道。
publicvoiddispatchOneEvent(LongeventId){NotificationEventevent=outboxRepository.lockPending(eventId);List<RecipientPlan>recipients=recipientResolver.resolve(event);for(RecipientPlanrecipient:recipients){for(Stringchannel:recipient.channels()){if(preferenceService.isMuted(recipient.userId(),event.eventType(),channel)){deliveryLogRepository.skip(event.id(),recipient.userId(),channel,"USER_MUTED");continue;}if(rateLimitService.exceeded(recipient.userId(),event.eventType(),channel)){deliveryLogRepository.skip(event.id(),recipient.userId(),channel,"RATE_LIMITED");continue;}deliverySender.send(event,recipient,channel);}}outboxRepository.markDispatched(event.id());}这两段代码故意保持简短,因为重点不在语法,而在边界。
业务事务只保证“事件被记录”。它不保证邮件一定已经发出。
发送 worker 可以失败,可以重试,可以暂停,也可以切换供应商。它不应该反向影响工单是否关闭。
接收人和渠道不写死在业务代码里。真实系统可以从通知策略表、角色关系、用户偏好和业务对象关系里计算。
图4:业务事务写通知事件,发送 worker 按策略异步处理渠道。
六、测试重点:少测发送SDK,多测策略边界
通知测试不要把主要精力放在第三方 SDK 是否真的能发邮件。SDK 的联通性可以用集成环境验证;日常单元测试更应该覆盖策略边界。
建议至少覆盖这些测试:
1. 工单关闭成功后,只写入一条 WORK_ORDER_CLOSED 事件。 2. 同一 event_key 重复写入时,不产生重复事件。 3. 申请人生成站内信和邮件,资产管理员只生成站内信。 4. 用户退订邮件后,邮件发送记录标记为 USER_MUTED。 5. 频控命中后,发送记录标记为 RATE_LIMITED。 6. 邮件发送失败后,retry_count 增加,next_retry_time 后移。 7. 邮件失败不影响工单状态 CLOSED。如果要写成 JUnit,可以聚焦一两个核心测试,而不是把所有发送渠道都 mock 一遍。
@TestvoidshouldWriteOnlyOneEventWhenWorkOrderClosedTwice(){Fixturefx=Fixture.processingWorkOrder("WO-20260924-001");fx.workOrderService().closeWorkOrder(fx.closeCommand());fx.workOrderService().closeWorkOrder(fx.sameRequestCommand());assertEquals("CLOSED",fx.workOrderRepository().status());assertEquals(1,fx.outboxRepository().countByEventKey("WORK_ORDER_CLOSED:WO-20260924-001"));}@TestvoidshouldSkipEmailWhenUserMutedThisEventType(){Fixturefx=Fixture.event("WORK_ORDER_CLOSED");fx.preferenceService().mute(1008L,"WORK_ORDER_CLOSED","EMAIL");fx.dispatcher().dispatchOneEvent(fx.eventId());assertEquals("SKIPPED",fx.deliveryLog().status(1008L,"EMAIL"));assertEquals("USER_MUTED",fx.deliveryLog().errorCode(1008L,"EMAIL"));assertEquals("SENT",fx.deliveryLog().status(1008L,"INBOX"));}这类测试比“调用邮件SDK返回 true”更有价值。因为通知系统真正容易出错的地方,是重复、误发、漏发、退订不生效、失败重试失控。
七、SQL验证:上线后查通知有没有乱发
通知上线后,要能用 SQL 看清楚是否乱发、漏发和重试异常。
检查同一事件是否重复:
SELECTevent_key,COUNT(*)AScntFROMmsg_event_outboxGROUPBYevent_keyHAVINGCOUNT(*)>1;预期结果:
empty set检查邮件失败率:
SELECTchannel,status,COUNT(*)AScntFROMmsg_delivery_logWHEREcreate_time>=DATE_SUB(NOW(),INTERVAL1DAY)GROUPBYchannel,status;这条不是要求失败为 0,而是要能看趋势。如果邮件失败突然升高,要排查 SMTP、模板参数、收件地址质量或供应商限制。
检查是否有无限重试:
SELECTid,event_type,biz_no,retry_count,next_retry_timeFROMmsg_event_outboxWHEREretry_count>3ANDstatusIN('PENDING','RETRY');预期结果:
empty set检查短信是否被普通事件滥用:
SELECTe.event_type,COUNT(*)ASsms_countFROMmsg_delivery_log dJOINmsg_event_outbox eONe.id=d.event_idWHEREd.channel='SMS'ANDd.create_time>=DATE_SUB(NOW(),INTERVAL7DAY)GROUPBYe.event_typeORDERBYsms_countDESC;这条要人工判断。如果大量普通工单、普通审批都走短信,就说明渠道策略失控了。
图5:通知验收要检查重复事件、失败率、重试次数和高成本渠道使用情况。
八、异常边界和上线验收
通知系统要提前讲清楚这些边界。
业务成功但通知失败:
主业务不应该被通知失败回滚。应该记录失败状态,进入重试或人工处理列表。
通知成功但用户没看到:
系统只能证明消息已发送、已投递或已读,不能保证用户真的理解了内容。关键任务不能只靠通知,还要有待办列表。
重复事件:
同一个业务事件必须有稳定幂等键。没有幂等键,重复提交和重试会造成重复通知。
用户退订:
非强制消息要尊重用户偏好。强制消息也要有明确范围,比如安全告警、审批待办、合规通知。
渠道降级:
邮件失败是否改发站内信,短信失败是否改发邮件,要按事件等级决定。不要所有失败都自动升级到短信。
模板参数缺失:
模板参数缺失应当在发送前校验。不能让用户收到“{workOrderNo} 已关闭”这种半成品消息。
上线验收可以按下面清单执行:
- 每类业务事件都有明确通知策略。
- 普通通知、重要通知、紧急通知的渠道不同。
- 同一事件有稳定
event_key,重复触发不会重复发送。 - 业务事务只写 outbox,不直接调用外部发送服务。
- 站内信、邮件、短信的发送记录分开保存。
- 用户偏好和退订规则能生效。
- 频控能阻止短时间重复提醒。
- 失败重试有次数上限和下次重试时间。
- 模板参数缺失会阻止发送并记录错误。
- SQL 能查出重复事件、失败率、无限重试和短信滥用。
九、小结和延伸阅读
消息通知的目标不是“能发出去”,而是“在合适的时间,用合适的渠道,把合适的信息发给合适的人”。这句话听起来像原则,但落到系统里就是事件模型、收件人规则、渠道策略、outbox、幂等、退订、频控和发送日志。
如果系统还很小,可以先做站内信和 outbox;等通知量上来,再扩展邮件、短信和即时通讯。不要一开始把所有渠道都接上,也不要让外部渠道失败影响主业务。把通知边界打稳,后续再做模板管理、消息中心和运营统计会顺很多。
延伸阅读:
- Microsoft:Transactional Outbox pattern
- Spring Framework:声明式事务管理
- MySQL 8.4:CREATE TABLE