Skip to content

设计一个通知系统(推送/短信/邮件)

提出问题

通知系统几乎是每个互联网应用的标配——用户注册要发验证码、下单成功要推送通知、营销活动要群发短信。但真正生产级的通知系统远不止调个 SDK 发出去这么简单:多通道怎么抽象、频率怎么控制、重复通知怎么去重合并、第三方通道挂了怎么办、发了错误内容怎么撤回?这些才是面试官想听到的深度。实际生产环境中,一个通知系统每天要处理百万级甚至亿级通知,任何通道故障都可能导致核心业务受阻。

分析问题

通道抽象与路由策略

通知系统的第一层设计是通道抽象。定义统一的 Notification 接口,各通道(Push / SMS / Email / 站内信)实现各自的适配器。业务方只调用 notifyService.send(userId, content),底层路由层根据用户偏好、通道优先级、通知类型自动选择通道。

java
// 统一的通道抽象接口
public interface NotificationChannel {
    SendResult send(NotificationRequest request);
    ChannelType type();
    boolean isAvailable();
}

// Push 通道实现
@Component
public class PushChannel implements NotificationChannel {
    @Override
    public SendResult send(NotificationRequest request) {
        // 调用 APNs / FCM / 自研长连接
        PushRequest push = PushRequest.builder()
            .deviceToken(request.getDeviceToken())
            .title(request.getTitle())
            .body(request.getContent())
            .build();
        return pushService.send(push);
    }
}

// 路由层:根据优先级和用户偏好选择通道
@Component
public class NotificationRouter {
    public ChannelRoute route(NotificationRequest request) {
        // P0 通知(验证码、支付):Push + SMS 双通道并行
        if (request.getPriority() == Priority.P0) {
            return ChannelRoute.parallel(PushChannel.class, SmsChannel.class);
        }
        // P1 通知(订单状态变更):优先 Push,失败降级 SMS
        if (request.getPriority() == Priority.P1) {
            return ChannelRoute.failover(PushChannel.class, SmsChannel.class);
        }
        // P2(营销推送):仅 Push,可延迟
        return ChannelRoute.single(PushChannel.class);
    }
}

频率控制与去重合并

高频通知是用户体验的杀手。双 11 期间一个用户可能同时收到 10 条"限时优惠"推送——这就是频率控制存在的意义。用 Redis 滑动窗口实现用户+通道维度的限流:

java
// 滑动窗口频率控制
public class RateLimiter {
    private static final String KEY_PREFIX = "rate:user:";
    
    public boolean allow(String userId, ChannelType channel, int limit, int windowSeconds) {
        String key = KEY_PREFIX + userId + ":" + channel.name();
        long now = System.currentTimeMillis() / 1000;
        long windowStart = now - windowSeconds;
        
        // 移除窗口外的记录
        redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
        long count = redisTemplate.opsForZSet().zCard(key);
        
        if (count < limit) {
            redisTemplate.opsForZSet().add(key, String.valueOf(now), now);
            redisTemplate.expire(key, Duration.ofSeconds(windowSeconds * 2));
            return true;
        }
        return false;
    }
}

去重策略更重要:相同内容 3 秒内重复触发时合并发送;同一个订单的多次状态变更(已支付→已发货→配送中)合并为一条"订单状态已更新"通知。合并逻辑用 MQ 延迟消息实现——先放入延迟队列,等待 3 秒去重窗口,再发送最终版本。

高可用与幂等设计

第三方通道不可用时,不能阻塞主流程。设计通道降级链:Push 不可用 → 自动降级到 SMS → SMS 不可用 → 降级到站内信。P0 通知采用多通道并行发送(主通道+备用通道同时发,先到为主),用 MQ 隔离不同优先级的 Topic 队列。

幂等设计同样关键:业务方因分布式重试可能重复调用。以 biz_id + channel 为唯一键去重,重复请求直接返回成功:

sql
-- 通知幂等表
CREATE TABLE notification_idempotent (
    biz_id VARCHAR(64) NOT NULL,
    channel VARCHAR(16) NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL,
    PRIMARY KEY (biz_id, channel)
) ENGINE=InnoDB;

回执处理与撤回

Push 通道的回执(送达/点击/已读)通过回调 Webhook 接收,写入 MySQL 后异步归档到 ClickHouse 做分析。回执数据量大,直接写 MySQL 会影响性能,所以用 MQ 缓冲:

java
// 回执处理:MQ 异步消费
@Component
public class ReceiptConsumer {
    @RabbitListener(queues = "notification.receipt")
    public void handleReceipt(ReceiptMessage msg) {
        // 先写入 MySQL 做实时查询
        receiptRepository.save(msg);
        // 异步归档到 ClickHouse 做分析
        kafkaTemplate.send("receipt_archive", msg);
    }
}

撤回方面:Push 推送支持撤回(iOS 的 mutable-content + Android 的 revoke),但 SMS 和 Email 已投递后无法撤回,只能靠审核机制前置拦截。生产上建议所有通知发送前经过内容审核队列,P0 通知自动审核通过,P2 营销通知需要人工审核。

总结

通知系统的五个关键设计要点:

设计维度方案投产要点
通道抽象统一接口 + 适配器模式路由层支持 failover 和 parallel 两种策略
频率控制Redis 滑动窗口按用户+通道维度,P0 通道放宽限制
去重合并MQ 延迟消息 + 3s 窗口biz_id 做幂等键
高可用通道降级链 + 多通道并行P0 走双通道,P2 可延迟
撤回Push 协议撤回 + 前置审核SMS/Email 不可撤回,审核前置是唯一防线

参考:阿里云推送 SDK 设计、Apple APNs 回执机制、通知系统架构实践

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。