
先交代一个背景。做电商的都知道,通知服务是最容易被忽略、又最容易出事故的模块。订单挂了还能找日志,短信发晚了用户直接开骂。我在公司里看到的场景很典型:订单服务里直接封装了阿里云短信 SDK,注册服务自己写 JavaMail 配置,运营后台又单独维护一套站内信逻辑。等短信供应商涨价,或者你想换一家到达率更高的通道,你会发现需要改动的服务比想象中多得多,那种感觉特别崩溃。
后来我们下决心搞了一个"通知中台"。这里我用"中台"这个词其实心里有点虚,本质上就是一个对业务方屏蔽渠道差异、统一负责发送逻辑的服务。它的目标很简单:业务方只负责说"给谁发什么",至于怎么发、走哪家供应商、失败了怎么办,全部扔给通知中心。
一、先看整体流程
先别管复杂的抽象,你可以把通知中心想象成一条流水线:
业务方请求 → 接入层做校验/幂等/转化任务 → 落库(状态为 INIT)
→ 异步抓取任务 → 模板渲染 → 路由选择 Channel/Provider
→ 调用发送 → 结果回写 → 失败则按策略重试/死信
我见过不少团队把通知服务做成了一个非常薄的 API:Controller 里直接调短信 SDK,没入库没状态。当时觉得没问题,上线后才发现一旦用户说"我没收到",根本没有任何记录可以查。所以第一条原则:发送必须落库,任务要有一个生命周期,状态从 INIT 开始,一直记录到 SENDING、SUCCESS、FAILED、DEAD。异步为什么要先落库?因为通知任务最好是一次持久化的"意图",之后无论是线程池、MQ 消费者还是定时任务去执行,都只是一次状态推进。
架构上我们并没有一开始就上很大的集群,而是把一个 Spring Boot 应用拆成了比较清晰的模块:
- API 层:
NotificationService接口,负责接收业务方请求 - 核心域模型:
NotificationTask、Channel、Provider、Template - 基础设施:
ChannelRouter、TemplateRenderService、重试 Job、Redis 等等
大概的分层见下面这张简化的图:
┌──────────────────────────────────────────────┐
│ 业务方(只关心"发什么通知") │
└──────────────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ 通知中心接入层:校验、幂等、创建任务、落库 │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ 异步任务队列(线程池 / MQ) │
│ → 模板渲染 → 路由选择 → 发送 │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ 具体渠道适配器 │
│ EmailChannel → SMTP / SES │
│ SmsChannel → 阿里云 / 腾讯云 │
│ StationChannel → 写站内信表 │
└──────────────────────────────────────────────┘
业务方调用体验是这样的:
java
NotificationRequest request = NotificationRequest.builder()
.type(NotifyType.ORDER_TIMEOUT)
.bizId(orderId.toString())
.channelTypeList(Arrays.asList(ChannelType.EMAIL, ChannelType.SMS))
.receiver("tom@example.com")
.params(ImmutableMap.of("orderId", order.getOrderNo(),
"timeout", "10分钟"))
.build();
notificationService.send(request);
bizId 是幂等用的,params 里是模板渲染需要的业务参数。模板编码、渠道选择、重试策略在后台配置好即可。
二、模板化:用 Thymeleaf 把内容渲染整明白
最早做通知,大家习惯用 String.format 拼内容,或者直接把 HTML 嵌在 Java 常量里。如果只是翻译几个订单变量,好说;但运营想要改一个字都要提需求,那就太累了。我们把内容和格式拆开,每个 notifyType 可以看作一套模板组,同一种通知在不同渠道有不同的模板。
用 Thymeleaf 渲染模板的好处是它既能输出 HTML,又能输出纯文本,语法上也顺手。我把模板放在数据库里,也可以通过 Git 管理,发布时不用动应用。模板示例:
邮件模板 order_timeout_email.html:
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"/></head>
<body>
<h2>订单超时提醒</h2>
<p>亲爱的<b th:text="${userName}">用户</b>,您好:</p>
<p>
订单 <span th:text="${orderId}"></span> 已超过
<span th:text="${timeout}"></span> 未支付,系统已自动关闭该订单。
</p>
<a th:href="${detailUrl}">查看订单详情</a>
</body>
</html>
短信模板 order_timeout_sms.txt:
【XX商城】您的订单${orderId}已超过${timeout}未支付,系统已自动关闭。如有疑问请联系客服,退订请回复TD。
渲染组件封装得很薄:
java
@Component
public class TemplateRenderService {
private final ThymeleafTemplateEngine engine;
public String render(String templateContent,
Map<String, Object> params,
TemplateMode mode) {
// 用 StringTemplateResolver 处理存储在数据库的模板内容
StringTemplateResolver resolver = new StringTemplateResolver();
resolver.setTemplateMode(mode); // HTML / TEXT
engine.setTemplateResolver(resolver);
Context context = new Context();
context.setVariables(params);
return engine.process(templateContent, context);
}
}
仔细看代码,你会发现每次渲染前都在给 engine 设置 resolver。理论上 StringTemplateResolver 无状态,你可以在初始化时只指定一次模板模式,但那段代码只是演示思路,实际项目里我建议直接创建一个独立的 TemplateEngine,并为每个渠道准备 resolver。Thymeleaf 引擎本身是线程安全的,process 方法可以放心调用。
模板按渠道编码区分。比如订单超时通知,邮件模板编码是 ORDER_TIMEOUT_EMAIL,短信模板编码是 ORDER_TIMEOUT_SMS。路由阶段根据通知类型和选择的渠道去找对应模板。说白了,业务参数只有一份,渠道差异全部用模板自己消化。产品改文案只需去后台改模板,不用再找你发版。
有时候短信内容因为 70 字限制,只取了 orderId 和 timeout;邮件排版复杂一些,可以引用十几个参数,这些都让模板自己搞。模板渲染是纯函数,好测试也容易追踪------查一条通知时,把模板和参数回放一遍就能还原当时的内容。
三、核心抽象:Channel 和 Provider
真正让我敢在公司里重构这堆逻辑的底气,是抽出了两个接口:Channel 和 Provider。
Channel:表示一种消息触达通道,比如异步邮件、短信、站内信。一个 Channel 内可能包含多个具体服务商。Provider:具体服务商适配器,比如阿里云短信、腾讯云短信。
很多人一开始容易把它们搞混。我理解是:Channel 定义的是"以什么方式触达",Provider 定义的是"实际由谁发送"。Channel 内部可以根据路由规则选择 Provider。
Channel 接口长得像这样:
java
public interface Channel {
ChannelType type();
boolean support(Notification notification);
SendResult send(Notification notification);
}
为什么要一个 support 方法?因为有些通道有前置条件。站内信 Channel 必须要求当前用户存在,短信 Channel 要求接收人有手机号。support 把判定逻辑交还给各自 Channel,避免业务方传参时还要做一堆判断。
EmailChannel 走 Spring 的 JavaMailSender,代码并不复杂。为了发 HTML 内容,需要构建 MimeMessage:
java
@Component
public class EmailChannel implements Channel {
private final JavaMailSender mailSender;
private final EmailProvider emailProvider; // 作为默认邮件服务器配置
@Override
public ChannelType type() { return ChannelType.EMAIL; }
@Override
public SendResult send(Notification notification) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(emailProvider.getFromAddress());
helper.setTo(notification.getReceiver());
helper.setSubject(notification.getSubject());
// content 在上一渲染阶段已经放入 Notification 对象
helper.setText(notification.getContent(), true);
mailSender.send(message);
return SendResult.success();
} catch (Exception e) {
return SendResult.fail(e.getMessage());
}
}
}
我特意没有在 EmailChannel 中写模板渲染逻辑,渲染应该在进入 Channel 之前做好,渲染结果放进 Notification 的 content 字段中。这样多个 Channel 可以共享同一份渲染后的内容,你也可以在日志里直接看到实际发出去的消息全文。
短信的 Channel 会有多 Provider。假设你接了阿里云、腾讯云,在实际使用中你可能把阿里云作为主通道,腾讯云作为备选。先看 Provider 定义:
java
public interface SmsProvider {
String name();
SmsSendResult send(SmsRequest request);
}
阿里云实现里会调用对应的 SDK,代码很简单:
java
@Component
public class AliyunSmsProvider implements SmsProvider {
@Override
public String name() { return "aliyun"; }
@Override
public SmsSendResult send(SmsRequest request) {
try {
CommonRequest req = new CommonRequest();
// ... 组装参数、签名、模板参数等
SendSmsResponse resp = client.getAcsResponse(sendReq);
if ("OK".equals(resp.getCode())) {
// 保留供应商返回的 requestId,后续状态查询要用
return SmsSendResult.success(resp.getRequestId());
}
return SmsSendResult.fail(resp.getCode(), resp.getMessage());
} catch (Exception e) {
return SmsSendResult.fail(e.getMessage());
}
}
}
SmsChannel 要做的事就是根据路由策略挑选一个 Provider 并发送。这里我用了一个 SmsRouter,可以简单理解成一个按规则返回 Provider 的组件。
java
@Component
public class SmsChannel implements Channel {
private final List<SmsProvider> providers;
private final SmsRouter smsRouter;
@Override
public ChannelType type() { return ChannelType.SMS; }
@Override
public SendResult send(Notification notification) {
SmsProvider provider = smsRouter.choose(notification, providers);
if (provider == null) {
return SendResult.fail("no available sms provider");
}
SmsRequest smsRequest = SmsRequest.builder()
.mobile(notification.getReceiver())
.signName(notification.getSignName())
.templateCode(notification.getSmsTemplateCode())
.templateParam(notification.getSmsParamJson())
.build();
SmsSendResult result = provider.send(smsRequest);
if (result.isSuccess()) {
// 把供应商的 requestId 记录下来,为将来查询状态留了线索
return SendResult.success(result.getVendorReqId());
}
return SendResult.fail(result.getErrorMsg())
.withRecoverable(true)
.setProviderName(provider.name());
}
}
站内信是最朴素的实现。不需要调用任何外部服务,直接插入一张表。不同的是,用户可能并没有注册 userId,这需要业务方在请求中显式传 userId,而不是用 receiver 字符串去匹配。
java
@Component
public class StationChannel implements Channel {
private final StationMessageMapper stationMessageMapper;
@Override
public SendResult send(Notification notification) {
StationMessage msg = StationMessage.builder()
.userId(notification.getUserId())
.title(notification.getTitle())
.content(notification.getContent())
.read(false)
.createdAt(LocalDateTime.now())
.build();
stationMessageMapper.insert(msg);
return SendResult.success(msg.getId().toString());
}
}
把所有 Channel 收集到一个 Map 里,方便路由查找:
java
@Configuration
public class ChannelRegistry {
@Bean
public ChannelRouter channelRouter(List<Channel> channels) {
Map<ChannelType, Channel> channelMap = channels.stream()
.collect(Collectors.toMap(Channel::type, Function.identity()));
return new ChannelRouter(channelMap);
}
}
四、异步化:线程池和 MQ 不是二选一
同步发送通知最坑人的地方就是:外部服务一旦慢,你的业务接口也一起慢。短信通道平均 200ms,如果超时设置 5 秒,那核心下单接口就可能被短信拖死。所以我们把发送动作从主流程中剥离。
如果你的系统是单体,流量也不是特别夸张,先用一个线程池最合适。
java
@Bean("notificationExecutor")
public ThreadPoolTaskExecutor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(10000);
executor.setThreadNamePrefix("notify-exec-");
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
executor.initialize();
return executor;
}
调用方调了 send 接口后,代码内部只做"生成 NotificationTask 并落库",然后丢给线程池执行。只要 executor 的队列没满,主流程几乎是无延迟的响应。
后来随着微服务拆分,业务方各自部署,我们把通知中心独立成一个服务,通过 MQ 接收任务。每个业务方发送时,把通知任务内容作为消息发到 RocketMQ,通知服务消费并执行发送。这个演进也自然:
java
@PostMapping(value = "/notifications/send")
public void send(@RequestBody NotificationRequest request) {
NotificationTask task = convertAndSave(request);
rabbitTemplate.convertAndSend("notification.exchange", "notify.route", task);
}
用了 MQ 后,如果你只是把任务发进队列然后让消费者发短信,依然有风险:消费者拉到消息后应用突然宕机,消息丢了。那怎么办?我们的做法是,任务表里已经存在 INIT 状态,MQ 只是一个触发信号,真正的事实来源还是数据库。消费端拿到 taskId,然后加载 task 执行。如果消费挂了,任务表里还是 INIT,定时扫描的 Job 会把这些任务捞出来继续执行。所以说 MQ 和数据库不是替代关系,而是配合:MQ 用来快速触发,DB 用来兜底。
即便有线程池或 MQ,通知中心还应该对自己做限流。不能等到第三方供应商 qps 限制打回来了你才想到。我常用 Guava RateLimiter,非常轻:
java
private final RateLimiter smsRateLimiter = RateLimiter.create(50); // 每秒最多 50 条
public void sendSms(NotificationTask task) {
boolean acquire = smsRateLimiter.tryAcquire(Duration.ofSeconds(1));
if (!acquire) {
// 稍后重试
throw new RetryableException("sms channel qps over limit");
}
realSend(task);
}
限流被击中时不要立刻打日志报警,那是正常业务情况。把任务状态置回 WAITING,设置 nextRetryTime = now + 1s,让重试 Job 下一轮处理就行。
五、可靠性与重试
一条通知从接收到最终结果,我们通常要记录以下几类信息:
id,主键biz_id和biz_type,业务方幂等键,比如订单超时通知里biz_type = ORDER_TIMEOUT,biz_id = orderNochannel_type,目前是 EMAIL / SMS / STATIONnotify_type,通知业务类型,对应一组模板和策略template_code,实际渲染使用的模板编码receiver,接收人,数据库里建议加密存储,日志里必须脱敏params_digest,业务参数 JSON,也建议加密content,渲染后的完整内容,方便事后查看"用户到底收到了什么"status,用INIT、SENDING、SUCCESS、FAILED、DEAD这几个枚举retry_count、max_retry_count,已经尝试了几次,最多计划尝试几次。next_retry_time,用来定时重试的字段last_error,最后一次错误信息create_time、update_time,不多解释
这部分设计没有太多技巧,重要的是想清楚重试流程。看图:
save(task=INIT)
↓ 放入异步执行
置为 SENDING
↓ channel.send()
成功 → SUCCESS,结束
失败 → 判断是否可重试
是 → retry_count + 1,计算 next_retry_time,置回 INIT/PENDING
否 → 置为 FAILED(再往后可能是 DEAD)
重试时间基本都按指数退避来算:第一次失败 1 分钟后试,第二次 5 分钟,第三次 30 分钟......上限看业务需要,验证码最好 3 次以内,营销邮件可以多一点。
重试 Job 用 Spring @Scheduled 就先够用:
java
@Scheduled(fixedDelay = 30000)
public void retryFailedTask() {
List<NotificationTask> tasks = taskMapper.selectRetryable(
LocalDateTime.now(), 100);
for (NotificationTask task : tasks) {
if (task.getRetryCount() >= task.getMaxRetryCount()) {
handleDead(task);
continue;
}
task.setStatus(TaskStatus.SENDING);
taskMapper.updateById(task);
// 再丢回异步队列去执行
sendProcess(task.getId());
}
}
有一个细节很多人会忽略:如果是外部渠道超时,但你不知道短信有没有被渠道成功发出,直接盲重发就可能造成用户收到多条同内容短信。这种情况下不要立刻把任务标记为 FAILED,而是先调用供应商的查询接口,看看这条消息到底有没有发出去。我们在设计 SmsChannel 时保留了 vendor_req_id,就是为了支持这个查询动作。
到了最大重试次数,任务进入 DEAD 状态。死信必须有人处理,不能光写在日志里。我们会发一个钉钉告警给运维:
java
public void handleDead(NotificationTask task) {
task.setStatus(TaskStatus.DEAD);
taskMapper.updateById(task);
alertService.sendToOps("通知死信", String.format(
"任务[%s],渠道=%s,接收人=%s,错误=%s",
task.getId(), task.getChannelType(),
DesensUtil.mask(task.getReceiver()), task.getLastError()));
}
验证码这种强时效业务,一般用户会点击"重新发送"。此时我们查到旧任务已经是 DEAD,就允许创建一个新任务,同时把旧验证码置为无效。其他类型死信还是要运营每天关注去重发或者手动处理。
六、路由降级和频控
邮件、短信、站内信,这几种渠道的可达性完全不一样。短信一般都会接至少两个供应商,天然要做主备切换。路由策略不能写死在代码里,而是要根据通道健康状态、发送成本、业务紧急程度来实时决策。
我最简单也最实用的做法是:在 Provider 层维护一个内存健康状态,由定时 Job 去 mock 发送或者检查供应商的熔断器。当路由被调用时,如果发现主供应商不可用,就切换到备用供应商。这里的"不可用"不只有返回失败,还包括连续超时比例过高,可能是网络问题,此时主通道也应该暂时破产。
跨渠道降级则要更谨慎。比如用户收到验证码短信,短信通道挂了,你不能降级成邮件或者站内信,因为用户可能不会去站点看。降级要业务方明确允许才开放。我会在通知任务上带一个 allowChannels 集合,如果业务方只允许短信,那短信失败就只能重试,不能再跳到别的方式。
另外很多通知不只是一种发送需求。订单催付、营销活动、验证码的频控规则差异巨大。没有频控,你的通知中心可能被人薅成短信轰炸机。频控主要按几个维度:
- 同一用户同一类型,频率限制,比如验证码 1 分钟内最多 3 次
- 同一手机号一天的营销总条数限制
- 渠道总 QPS 限制
实现用 Redis 做计数器很自然:
java
public boolean canSend(String phone, NotifyType type) {
String key = "notify:limit:" + type.getCode() + ":" + phone;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, Duration.ofMinutes(1));
}
return count <= maxPermits(type, phone);
}
当频控触发,除了直接拒绝,还可以根据场景决定是延迟重试还是降级为低打扰渠道。比如营销活动短信被限流了,可以自动转为站内信;验证码被限流了,你可以提示用户 60 秒后再获取。
七、幂等、脱敏和审计
幂等
用户连续点了两次"获取验证码",或者前端超时重试导致同一个订单催付通知被发送两次。这是通知中心必须处理的。最简单的办法是在任务表上建联合唯一索引:
sql
unique key uk_biz_type (biz_type, biz_id, channel_type)
插入前先查一遍,或者干脆 INSERT IGNORE(具体根据你用的数据库选策略)。如果遇到高并发,MySQL 的唯一索引就足够扛了。如果业务场景极端,可以用 Redis SETNX 加锁:
java
public void createTaskIfAbsent(NotificationRequest req) {
String lockKey = String.format("notify:%s:%s:%s",
req.getType(), req.getBizId(), req.getFirstChannel());
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (success) {
try {
int rows = taskMapper.insert(task);
if (rows > 0) {
// 创建成功,继续后续流程
}
} finally {
// 释放锁
}
}
}
如果任务已经存在,代码可以查询已有任务并返回它的当前状态。这样业务方不必拿"已存在"当异常,幂等语义就是"同一个业务请求最终只会产生一条消息"。
敏感信息处理
用户的手机号、邮箱、地址,一定要加密存储。数据库落的是密文,只有发送时在内存中解密。日志里严禁打印原始信息,用脱敏工具统一处理:
java
public class DesensUtil {
public static String maskMobile(String mobile) {
return mobile.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}
public static String maskEmail(String email) {
int idx = email.indexOf('@');
if (idx <= 1) return email;
return email.substring(0, 1) + "***" + email.substring(idx);
}
}
千万别小看这一步。很多信息泄露就是通过日志泄露出去的,光是脱敏还不够,日志文件前要加上权限控制,过期要删除。
审计为什么要做
好的通知中心必须能回答一个问题:用户说他没收到短信,到底发生了什么。你不能只回一句"我们查一下"然后干瞪眼。我会把每一次发送的关键步骤都记录成一条审计日志,至少包含:
- 业务方是谁,哪个系统发的,来源 IP
- 请求参数(已经脱敏)
- 最终选择了哪个 Channel,哪个 Provider
- 发送耗时,状态,重试次数
- 第三方返回的结果或错误信息
用 Spring AOP 也可以,直接在服务层打点更自然。我个人的习惯是在 NotificationService 的方法里埋一个简单的日志,异步发送到日志系统。关键点是日志中永远不能出现完整手机号、邮箱和明文密码类的信息。
AOP 大概长这样:
java
@Aspect
@Component
public class AuditLogAspect {
@Around("@annotation(audit)")
public Object around(ProceedingJoinPoint pjp, Audit audit) throws Throwable {
Object[] args = pjp.getArgs();
String req = DesensUtil.serialize(args);
long begin = System.currentTimeMillis();
try {
Object result = pjp.proceed();
auditLogService.syncLog(req, result.toString(), 0);
return result;
} catch (Throwable e) {
auditLogService.syncLog(req, e.getMessage(), 1);
throw e;
}
}
}
这样从接入到最终发送,每个环节都留了记录。后面排查客诉时,不像从前那样全靠猜。
八、最后说点自己的体会
通知中心看起来很基础,其实特别考验抽象能力和边界把握能力。一开始不用设计得太复杂,但落库、模板渲染、异步、重试这四件事一定要有。如果你正在改造一个乱七八糟的通知模块,我建议这样排优先级:第一,先把发送记录落库;第二,把模板引擎替换掉手工拼接;第三,统一 Channel 和 Provider 接口;第四,再考虑 MQ 和复杂的路由降级。等这些都完成了,你的通知模块就不再是散落的补丁,而是一个真正能稳定支撑你业务的基础设施。
上面这些内容,基本是我前后重构两个系统总结出来的路数。代码里的细节未必适合每一个团队,但大方向应该没错。最关键的不是选什么技术,而是关注消息可靠性和用户体验。通知这东西,出问题通常是隐性的,用户可能只是默默流失。别让一条短信毁掉一次交易。