企业级消息中心架构设计与实践:多渠道统一推送平台

摘要

在现代互联网应用中,消息推送是用户触达的核心手段。从站内信、短信、邮件到APP推送、实时通知,企业需要一个统一的消息中心来管理多种推送渠道。本文基于神话B2C电商平台的实践经验,详细阐述了企业级消息中心的架构设计、技术选型、核心功能实现以及高可用保障方案,重点介绍了多渠道适配、实时推送(WebSocket/SSE)、消息队列集成等关键技术,为构建统一消息平台提供参考。


目录


一、为什么需要消息中心?

1.1 传统推送方式的痛点

在微服务架构流行之前,各业务系统独立实现消息推送功能,这种"烟囱式"架构带来了诸多问题:

问题1:重复建设,资源浪费

复制代码
订单服务 → 自己实现短信推送
营销服务 → 自己实现短信推送
支付服务 → 自己实现短信推送
...

每个服务都要:

  • 对接短信服务商API
  • 维护API密钥和配置
  • 处理发送失败重试
  • 记录发送日志

问题2:多渠道割裂,无法统一管理

同一个业务事件(如订单支付成功),需要推送到多个渠道:

  • 站内信通知
  • 短信通知
  • APP推送
  • 邮件账单

但这些渠道分散在不同系统,无法统一配置、统计和监控。

问题3:缺乏灵活性

  • 模板硬编码在代码中,修改需要发版
  • 无法动态调整推送策略(如渠道降级)
  • 难以支持A/B测试

问题4:成本管控困难

  • 无法统计各业务的推送量
  • 难以设置配额和限流
  • 费用分摊不清晰

1.2 消息中心的核心价值

统一消息中心作为基础设施层的核心服务,解决了上述所有问题:
推送渠道
消息中心
业务服务层
订单服务
营销服务
支付服务
用户服务
统一API
渠道管理
模板管理
推送引擎
站内信
短信
邮件
APP推送
WebSocket
SSE

核心优势

  1. 统一接入:业务方只需调用一个API
  2. 多渠道支持:9种渠道(站内信、短信、邮件、APP推送、WebSocket、SSE、企业微信、钉钉、小程序)
  3. 灵活配置:模板化管理,无需发版
  4. 成本可控:统一限流、配额管理
  5. 数据可视:统一统计、监控、报表

1.3 适用场景

消息中心适用于以下场景:

场景分类 具体场景 推荐渠道
交易通知 订单确认、支付成功、发货通知 站内信 + 短信 + APP推送
营销活动 优惠券发放、限时抢购、会员权益 站内信 + APP推送 + 小程序
系统通知 账号安全、服务变更、系统维护 站内信 + 邮件
实时协作 在线客服、工单处理、协同编辑 WebSocket + 站内信
企业内部 审批通知、任务提醒、OA消息 企业微信 + 钉钉
验证码 登录验证、支付验证、绑定手机 短信

二、消息中心核心概念

2.1 消息的生命周期

一条消息从创建到送达,经历完整的生命周期:
创建消息
开始推送
所有渠道成功
部分渠道成功
所有渠道失败
重试失败渠道
重试
超过重试次数
用户查看(站内信)
待发送
发送中
发送成功
部分成功
发送失败
已过期
已读

状态说明

  • 待发送(PENDING):消息已创建,等待推送
  • 发送中(SENDING):正在向各渠道推送
  • 发送成功(SUCCESS):所有渠道推送成功
  • 部分成功(PARTIAL_SUCCESS):部分渠道成功,部分失败
  • 发送失败(FAILED):所有渠道推送失败
  • 已过期(EXPIRED):超过有效期或重试次数上限

2.2 消息的组成要素

一条完整的消息包含以下要素:

yaml 复制代码
消息ID: msg_202601071200001
模板编码: ORDER_PAID_SUCCESS
消息类型: TRANSACTIONAL      # 交易类/营销类/通知类
业务类型: ORDER              # 订单/营销/用户/系统
接收用户: [userId: 10001]
推送渠道: [IN_APP, SMS, PUSH] # 多渠道组合
变量数据:
  orderNo: SO202601071200001
  amount: 299.00
  productName: 商品名称
优先级: HIGH                 # 高/中/低
过期时间: 2026-01-08 12:00:00

2.3 渠道的分类

消息中心支持9种推送渠道,可分为以下几类:

按实时性分类

实时性 渠道 延迟 适用场景
实时 WebSocket、SSE < 1秒 在线客服、协作编辑
准实时 APP推送 1-5秒 重要通知、营销活动
异步 短信、邮件、站内信 5-30秒 交易通知、系统通知

按触达率分类

触达率 渠道 说明
短信 99%以上,但成本高
APP推送、站内信 依赖用户在线/打开APP
邮件 可能进垃圾箱

按成本分类

成本 渠道 价格
短信 0.03-0.05元/条
APP推送 按量计费,约0.001元/条
站内信、WebSocket、SSE 仅服务器成本

2.4 模板的作用

模板是消息内容的蓝图,实现了内容与代码的解耦:

传统方式(硬编码)

java 复制代码
// ❌ 修改内容需要发版
String smsContent = "您的订单" + orderNo + "已支付成功,金额" + amount + "元";

模板方式

java 复制代码
// ✅ 模板可在管理后台动态修改
模板定义:
  短信模板: "您的订单${orderNo}已支付成功,金额${amount}元"
  变量定义: [
    {name: "orderNo", type: "String", required: true},
    {name: "amount", type: "BigDecimal", required: true}
  ]

代码调用:
  messageClient.send("ORDER_PAID_SUCCESS", userId,
    Map.of("orderNo", "SO123", "amount", 299.00)
  );

模板的优势

  • 内容修改无需发版
  • 多渠道内容统一管理
  • 支持变量校验和类型转换
  • 支持审核流程

三、业界主流方案对比

在设计消息中心之前,我们调研了业界主流的消息推送平台。

3.1 友盟消息推送(第三方SaaS)

定位:移动APP推送服务

核心特性

  • ✅ 专注于APP推送(iOS、Android)
  • ✅ 开发者友好,SDK集成简单
  • ✅ 支持标签、别名、地理位置推送
  • ✅ 提供推送统计和分析

局限性

  • ❌ 仅支持APP推送,不支持站内信、短信等
  • ❌ SaaS服务,数据在第三方
  • ❌ 无法定制化开发

适用场景:小型APP,无自建需求

3.2 极光推送(第三方SaaS)

定位:移动开发者服务平台

核心特性

  • ✅ APP推送、短信、即时通讯全覆盖
  • ✅ 稳定性好,支持千万级并发
  • ✅ 提供VIP专线通道(付费)
  • ✅ 支持富媒体推送(图片、音频)

局限性

  • ❌ 费用较高(按推送量计费)
  • ❌ 数据无法自主管控
  • ❌ 无法与企业内部系统深度集成

适用场景:中大型APP,预算充足

3.3 阿里云消息服务(云服务)

定位:企业级消息队列与推送服务

核心特性

  • ✅ 提供消息队列(RocketMQ)+ 移动推送
  • ✅ 与阿里云生态深度集成
  • ✅ 支持高并发、高可用
  • ✅ 按量付费,成本可控

局限性

  • ❌ 需要组合多个云产品
  • ❌ 学习成本高
  • ❌ 锁定云厂商

适用场景:已采用阿里云的企业

3.4 自建消息中心(自研)

定位:企业内部统一消息平台

核心特性

  • ✅ 完全自主可控
  • ✅ 深度定制,满足特殊需求
  • ✅ 数据安全,符合合规要求
  • ✅ 支持所有渠道(9种)
  • ✅ 与企业系统无缝集成

局限性

  • ⚠️ 开发成本高
  • ⚠️ 需要专业团队维护

适用场景:大中型企业,有技术团队

3.5 方案对比总结

维度 友盟/极光 阿里云 自建(神话)
渠道数量 1-2种 2-3种 9种
数据安全 第三方 云端 自主
定制能力
成本 低(长期)
技术门槛
维护成本

神话消息中心的选择理由

  1. 业务需求:需要支持9种渠道,第三方无法满足
  2. 数据安全:金融级安全要求,必须自主可控
  3. 技术积累:有成熟的微服务团队
  4. 长期成本:自建后边际成本低

四、整体架构设计

4.1 分层架构

神话消息中心采用经典的四层架构:

复制代码
┌─────────────────────────────────────────────────┐
│           前端展现层(管理后台)                 │
│  • 渠道管理  • 模板管理  • 推送记录  • 数据统计  │
└────────────────────┬────────────────────────────┘
                     │ HTTPS
┌────────────────────┴────────────────────────────┐
│              接口层(API网关)                   │
│  • 路由转发  • 鉴权认证  • 限流熔断  • 日志监控  │
└────────────────────┬────────────────────────────┘
                     │
┌────────────────────┴────────────────────────────┐
│              业务服务层                          │
│  ┌─────────────┐  ┌─────────────┐              │
│  │ 管理服务     │  │ 发送服务     │              │
│  │ • 渠道配置   │  │ • 消息分发   │              │
│  │ • 模板管理   │  │ • 渠道路由   │              │
│  │ • 用户群     │  │ • 发送执行   │              │
│  └─────────────┘  └─────────────┘              │
│  ┌─────────────┐  ┌─────────────┐              │
│  │ 推送服务     │  │ 统计服务     │              │
│  │ • WebSocket  │  │ • 实时统计   │              │
│  │ • SSE推送    │  │ • 数据分析   │              │
│  └─────────────┘  └─────────────┘              │
└────────────────────┬────────────────────────────┘
                     │
┌────────────────────┴────────────────────────────┐
│              数据存储层                          │
│  • MySQL(消息、模板、配置)                     │
│  • Redis(连接管理、缓存、Pub/Sub)             │
│  • RocketMQ(消息队列、异步处理)               │
└─────────────────────────────────────────────────┘

各层职责

  1. 前端展现层:Vue3 + TypeScript,运营人员可视化管理
  2. 接口层:Spring Cloud Gateway,统一入口
  3. 业务服务层:核心业务逻辑,微服务拆分
  4. 数据存储层:数据持久化和缓存

4.2 核心服务模块

模块划分原则:高内聚、低耦合

模块 职责 技术栈
渠道管理 渠道配置、测试、启停 Spring Boot + MyBatis-Plus
模板管理 模板CRUD、审核、版本控制 Spring Boot + Freemarker
消息发送 消息创建、渠道路由、发送执行 Spring Boot + RocketMQ
推送服务 WebSocket/SSE连接管理、实时推送 Spring WebSocket/SSE
用户群管理 用户分组、群体推送 Spring Boot + MySQL
统计分析 实时统计、数据分析、报表 Spring Boot + Redis + ECharts

4.3 技术选型

后端技术栈
技术 版本 用途 选型理由
Java 17+ 开发语言 高性能、生态成熟
Spring Boot 3.3.0 微服务框架 开发效率高
Spring Cloud 2023.0.2 分布式解决方案 全家桶支持
MyBatis-Plus 3.5.6 ORM框架 简化CRUD
MySQL 8.4.0 关系型数据库 事务支持、成熟稳定
Redis Latest 缓存+Pub/Sub 高性能、支持多种数据结构
RocketMQ 5.x 消息队列 支持定时消息、事务消息
Redisson 3.27.2 分布式锁 功能强大、易用
MapStruct Latest 对象转换 编译时生成,性能好
前端技术栈
技术 版本 用途
Vue 3.4+ 前端框架
TypeScript 5.x 类型安全
Element Plus 2.5+ UI组件库
ECharts 5.5+ 数据可视化
Pinia 2.1+ 状态管理

4.4 数据库设计

消息中心共有7张核心表:

表名 说明 核心字段
mc_channel_config 渠道配置 channel_type, provider, api_config, rate_limit
mc_message_template 消息模板 template_code, message_type, content_*, variables
mc_message_record 消息主记录 template_code, receiver_id, status, expire_time
mc_message_send_detail 发送明细 record_id, channel_type, receiver, send_status
mc_user_group 用户群 group_name, group_type, member_count
mc_user_group_member 用户群成员 group_id, user_id
mc_push_record 推送记录 group_id, total_count, success_count

五、多渠道统一管理

5.1 渠道抽象设计

为了支持9种推送渠道并保持可扩展性,我们采用策略模式 + 工厂模式的组合:

核心接口定义

java 复制代码
/**
 * 渠道提供者接口
 * 所有渠道实现类必须实现此接口
 */
public interface ChannelProvider {
    /**
     * 获取渠道编码
     * @return IN_APP, SMS, EMAIL, PUSH等
     */
    String getChannelCode();

    /**
     * 发送消息
     */
    ChannelSendResult send(ChannelSendRequest request);

    /**
     * 测试渠道配置
     */
    ChannelTestResult test(ChannelTestRequest request);
}

渠道工厂

java 复制代码
@Component
public class ChannelProviderFactory {

    // Spring自动注入所有ChannelProvider实现
    private final Map<String, ChannelProvider> providerMap;

    @Autowired
    public ChannelProviderFactory(List<ChannelProvider> providers) {
        this.providerMap = providers.stream()
            .collect(Collectors.toMap(
                ChannelProvider::getChannelCode,
                Function.identity()
            ));
    }

    public ChannelProvider getProvider(String channelCode) {
        ChannelProvider provider = providerMap.get(channelCode);
        if (provider == null) {
            throw new BusinessException("不支持的渠道: " + channelCode);
        }
        return provider;
    }
}

5.2 支持的9种渠道

渠道类型 编码 服务商 使用场景 实现类
站内信 IN_APP 自建 所有通知 InAppChannelProvider
短信 SMS 阿里云 验证码、重要通知 SmsChannelProvider
邮件 EMAIL SMTP/阿里云 账单、报表 EmailChannelProvider
APP推送 PUSH 极光/个推 实时提醒 PushChannelProvider
WebSocket WEBSOCKET 自建 在线客服、协作 WebSocketChannelProvider
SSE SSE 自建 Web实时通知 SseChannelProvider
企业微信 WECHAT_WORK 企业微信API B端内部通知 WechatWorkChannelProvider
钉钉 DINGTALK 钉钉开放平台 企业通知 DingtalkChannelProvider
小程序 MINI_PROGRAM 微信开放平台 小程序订阅 MiniProgramChannelProvider

5.3 渠道配置管理

每个渠道有独立的配置,存储在 mc_channel_config 表:

java 复制代码
@Data
@TableName("mc_channel_config")
public class ChannelConfig {
    private Long id;

    // 渠道信息
    private String channelType;      // 渠道类型(IN_APP/SMS/EMAIL等)
    private String channelName;      // 渠道名称
    private String provider;         // 服务商(ALIYUN/JIGUANG等)

    // API配置(JSON格式,加密存储)
    private String apiConfig;        // {"apiKey": "xxx", "apiSecret": "yyy"}

    // 限流配置
    private Integer rateLimit;       // 每小时限制条数
    private Integer dailyLimit;      // 每日限制条数

    // 重试配置
    private Integer maxRetry;        // 最大重试次数
    private Integer retryInterval;   // 重试间隔(秒)

    // 状态
    private Integer status;          // 1-启用 0-停用
    private Integer priority;        // 优先级(用于渠道降级)
}

配置示例(短信渠道)

json 复制代码
{
  "channelType": "SMS",
  "channelName": "阿里云短信",
  "provider": "ALIYUN",
  "apiConfig": {
    "accessKeyId": "LTAI5t***",
    "accessKeySecret": "nE8K***",
    "signName": "神话商城",
    "regionId": "cn-hangzhou"
  },
  "rateLimit": 10000,
  "dailyLimit": 100000,
  "maxRetry": 3,
  "retryInterval": 60,
  "status": 1,
  "priority": 1
}

5.4 渠道实现示例

短信渠道实现

java 复制代码
@Component
public class SmsChannelProvider implements ChannelProvider {

    private static final TraceLogger log = TraceLogger.getLogger(SmsChannelProvider.class);

    @Autowired
    private ChannelConfigRepository channelConfigRepository;

    @Override
    public String getChannelCode() {
        return "SMS";
    }

    @Override
    public ChannelSendResult send(ChannelSendRequest request) {
        // 1. 加载渠道配置
        ChannelConfig config = channelConfigRepository.findByChannelType("SMS");
        if (config == null || config.getStatus() == 0) {
            return ChannelSendResult.failed("短信渠道未配置或已停用");
        }

        // 2. 解析API配置
        JSONObject apiConfig = JSON.parseObject(config.getApiConfig());
        String accessKeyId = apiConfig.getString("accessKeyId");
        String accessKeySecret = apiConfig.getString("accessKeySecret");
        String signName = apiConfig.getString("signName");

        // 3. 调用阿里云短信API
        try {
            IAcsClient client = new DefaultAcsClient(
                DefaultProfile.getProfile("cn-hangzhou", accessKeyId, accessKeySecret)
            );

            SendSmsRequest smsRequest = new SendSmsRequest();
            smsRequest.setPhoneNumbers(request.getReceiver());
            smsRequest.setSignName(signName);
            smsRequest.setTemplateCode(request.getTemplateCode());
            smsRequest.setTemplateParam(JSON.toJSONString(request.getVariables()));

            SendSmsResponse response = client.getAcsResponse(smsRequest);

            if ("OK".equals(response.getCode())) {
                log.info("短信发送成功, phone={}, bizId={}",
                         request.getReceiver(), response.getBizId());
                return ChannelSendResult.success(response.getBizId());
            } else {
                log.error("短信发送失败, code={}, message={}",
                          response.getCode(), response.getMessage());
                return ChannelSendResult.failed(response.getMessage());
            }

        } catch (Exception e) {
            log.error("短信发送异常", e);
            return ChannelSendResult.failed("发送异常: " + e.getMessage());
        }
    }

    @Override
    public ChannelTestResult test(ChannelTestRequest request) {
        // 测试短信配置是否正确
        try {
            JSONObject apiConfig = JSON.parseObject(request.getApiConfig());
            // 发送测试短信
            ChannelSendRequest testRequest = new ChannelSendRequest();
            testRequest.setReceiver(request.getTestReceiver());
            testRequest.setContent("【测试】这是一条测试短信");

            ChannelSendResult result = send(testRequest);

            return result.isSuccess()
                ? ChannelTestResult.success("测试成功")
                : ChannelTestResult.failed("测试失败: " + result.getMessage());
        } catch (Exception e) {
            return ChannelTestResult.failed("测试异常: " + e.getMessage());
        }
    }
}

5.5 渠道路由策略

当一条消息需要发送到多个渠道时,消息中心支持多种路由策略:

策略1:全部发送

java 复制代码
// 所有配置的渠道都发送
List<String> channels = Arrays.asList("IN_APP", "SMS", "PUSH");

策略2:优先级降级

java 复制代码
// 按优先级尝试,成功一个即停止
// 1. 尝试APP推送(成本低、实时)
// 2. 失败则发送短信(成本高、可靠)
if (!pushChannel.send(request).isSuccess()) {
    smsChannel.send(request);
}

策略3:A/B测试

java 复制代码
// 随机50%用户发送新渠道
if (Math.random() < 0.5) {
    newChannel.send(request);
} else {
    oldChannel.send(request);
}

5.6 渠道限流保护

为了避免短时间大量消息冲击第三方服务,消息中心实现了多级限流:

限流层级
通过
拒绝
通过
拒绝
通过
拒绝
消息发送请求
全局限流
渠道限流
返回限流错误
用户限流
发送消息

实现方案(Redis + Lua)

java 复制代码
@Component
public class RateLimiter {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 渠道级限流
     * @param channelType 渠道类型
     * @param limit 每小时限制数
     * @return true-通过 false-拒绝
     */
    public boolean tryAcquire(String channelType, int limit) {
        String key = "rate:limit:channel:" + channelType;
        Long count = redisTemplate.opsForValue().increment(key);

        if (count == 1) {
            // 首次访问,设置1小时过期
            redisTemplate.expire(key, 1, TimeUnit.HOURS);
        }

        return count <= limit;
    }

    /**
     * 用户级限流(防骚扰)
     * @param userId 用户ID
     * @param channelType 渠道类型
     * @param limit 每天限制数
     */
    public boolean tryAcquireUser(Long userId, String channelType, int limit) {
        String key = "rate:limit:user:" + userId + ":" + channelType + ":"
                   + LocalDate.now();
        Long count = redisTemplate.opsForValue().increment(key);

        if (count == 1) {
            redisTemplate.expire(key, 1, TimeUnit.DAYS);
        }

        return count <= limit;
    }
}

六、实时推送技术实现

实时推送是消息中心的核心能力,支持WebSocket和SSE两种模式。

6.1 WebSocket vs SSE

维度 WebSocket SSE
通信方式 全双工(双向) 半双工(服务器→客户端)
协议 独立协议(ws://) HTTP协议
浏览器支持 现代浏览器 现代浏览器(除IE)
自动重连 需手动实现 浏览器自动重连
传输格式 二进制/文本 文本(UTF-8)
复杂度 中等 简单
适用场景 在线客服、游戏 Web通知、实时数据

神话消息中心的选择

  • APP端:使用WebSocket(双向交互需求)
  • Web端:使用SSE(单向推送,更简单)

6.2 WebSocket连接管理

连接生命周期
业务服务 Redis WebSocket服务 客户端 业务服务 Redis WebSocket服务 客户端 loop [心跳保活] 1. 建立连接(携带token) 2. 验证token 3. 注册连接到本地Map 4. 记录连接信息(userId ->> nodeId) 5. 返回连接成功 心跳ping 心跳pong 6. 查询用户连接节点 7. 返回nodeId 8. 推送消息(如果是当前节点) 9. 发送消息 10. 关闭连接 11. 删除连接记录

连接管理器实现

java 复制代码
@Component
public class WebSocketConnectionManager {

    private static final TraceLogger log =
        TraceLogger.getLogger(WebSocketConnectionManager.class);

    // 本地连接映射: userId -> WebSocketSession
    private final ConcurrentHashMap<Long, WebSocketSession> localConnections =
        new ConcurrentHashMap<>();

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private String nodeId;  // 当前节点ID(从配置读取)

    /**
     * 注册WebSocket连接
     */
    public void register(Long userId, WebSocketSession session) {
        // 1. 存储到本地Map
        localConnections.put(userId, session);

        // 2. 记录到Redis(用于集群路由)
        String key = "push:connection:ws:" + userId;
        redisTemplate.opsForValue().set(key, nodeId, 2, TimeUnit.HOURS);

        log.info("WebSocket连接已注册, userId={}, nodeId={}", userId, nodeId);
    }

    /**
     * 移除连接
     */
    public void unregister(Long userId) {
        localConnections.remove(userId);
        redisTemplate.delete("push:connection:ws:" + userId);

        log.info("WebSocket连接已移除, userId={}", userId);
    }

    /**
     * 推送消息给指定用户
     */
    public boolean push(Long userId, String message) {
        WebSocketSession session = localConnections.get(userId);
        if (session == null || !session.isOpen()) {
            log.warn("用户未连接或连接已关闭, userId={}", userId);
            return false;
        }

        try {
            session.sendMessage(new TextMessage(message));
            log.info("消息推送成功, userId={}", userId);
            return true;
        } catch (IOException e) {
            log.error("消息推送失败, userId=" + userId, e);
            unregister(userId);
            return false;
        }
    }

    /**
     * 查询用户连接所在节点
     */
    public String findNodeId(Long userId) {
        String key = "push:connection:ws:" + userId;
        return redisTemplate.opsForValue().get(key);
    }

    /**
     * 获取在线用户数
     */
    public int getOnlineCount() {
        return localConnections.size();
    }
}

WebSocket端点实现

java 复制代码
@Component
@ServerEndpoint(value = "/ws/message", configurator = SpringConfigurator.class)
public class MessageWebSocketEndpoint {

    private static WebSocketConnectionManager connectionManager;
    private static JwtTokenUtil jwtTokenUtil;

    @Autowired
    public void setConnectionManager(WebSocketConnectionManager manager) {
        MessageWebSocketEndpoint.connectionManager = manager;
    }

    @Autowired
    public void setJwtTokenUtil(JwtTokenUtil util) {
        MessageWebSocketEndpoint.jwtTokenUtil = util;
    }

    private Long userId;
    private Session session;

    @OnOpen
    public void onOpen(Session session, @PathParam("token") String token) {
        this.session = session;

        // 1. 验证token
        try {
            this.userId = jwtTokenUtil.getUserIdFromToken(token);
        } catch (Exception e) {
            log.error("Token验证失败", e);
            try {
                session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "认证失败"));
            } catch (IOException ex) {
                // ignore
            }
            return;
        }

        // 2. 注册连接
        connectionManager.register(userId, session);

        log.info("WebSocket连接建立, userId={}", userId);
    }

    @OnClose
    public void onClose() {
        if (userId != null) {
            connectionManager.unregister(userId);
            log.info("WebSocket连接关闭, userId={}", userId);
        }
    }

    @OnError
    public void onError(Throwable error) {
        log.error("WebSocket错误, userId=" + userId, error);
        if (userId != null) {
            connectionManager.unregister(userId);
        }
    }

    @OnMessage
    public void onMessage(String message) {
        // 处理客户端消息(如心跳pong)
        if ("ping".equals(message)) {
            try {
                session.getBasicRemote().sendText("pong");
            } catch (IOException e) {
                log.error("心跳响应失败", e);
            }
        }
    }
}

6.3 SSE推送实现

SSE(Server-Sent Events)是HTML5标准,基于HTTP协议,更简单:

SSE控制器

java 复制代码
@RestController
@RequestMapping("/api/message/v1")
public class SseController {

    @Autowired
    private SseConnectionManager sseConnectionManager;

    /**
     * 建立SSE连接
     */
    @GetMapping("/sse/subscribe")
    public SseEmitter subscribe(@RequestParam String token) {
        // 1. 验证token
        Long userId = jwtTokenUtil.getUserIdFromToken(token);

        // 2. 创建SseEmitter(超时2小时)
        SseEmitter emitter = new SseEmitter(2 * 60 * 60 * 1000L);

        // 3. 注册连接
        sseConnectionManager.register(userId, emitter);

        // 4. 设置回调
        emitter.onCompletion(() -> sseConnectionManager.unregister(userId));
        emitter.onTimeout(() -> sseConnectionManager.unregister(userId));
        emitter.onError((e) -> sseConnectionManager.unregister(userId));

        // 5. 发送欢迎消息
        try {
            emitter.send(SseEmitter.event()
                .name("connected")
                .data("连接成功"));
        } catch (IOException e) {
            log.error("发送欢迎消息失败", e);
        }

        return emitter;
    }
}

SSE连接管理器

java 复制代码
@Component
public class SseConnectionManager {

    private final ConcurrentHashMap<Long, SseEmitter> localConnections =
        new ConcurrentHashMap<>();

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private String nodeId;

    public void register(Long userId, SseEmitter emitter) {
        localConnections.put(userId, emitter);
        redisTemplate.opsForValue().set("push:connection:sse:" + userId, nodeId, 2, TimeUnit.HOURS);
    }

    public void unregister(Long userId) {
        localConnections.remove(userId);
        redisTemplate.delete("push:connection:sse:" + userId);
    }

    public boolean push(Long userId, String eventName, Object data) {
        SseEmitter emitter = localConnections.get(userId);
        if (emitter == null) {
            return false;
        }

        try {
            emitter.send(SseEmitter.event()
                .name(eventName)
                .data(data));
            return true;
        } catch (IOException e) {
            log.error("SSE推送失败, userId=" + userId, e);
            unregister(userId);
            return false;
        }
    }
}

前端SSE接入

javascript 复制代码
// 建立SSE连接
const eventSource = new EventSource(`/api/message/v1/sse/subscribe?token=${token}`);

// 监听连接事件
eventSource.addEventListener('connected', (event) => {
  console.log('连接成功:', event.data);
});

// 监听消息事件
eventSource.addEventListener('message', (event) => {
  const message = JSON.parse(event.data);
  console.log('收到消息:', message);
  // 显示通知
  showNotification(message);
});

// 错误处理
eventSource.onerror = (error) => {
  console.error('SSE连接错误:', error);
  // 浏览器会自动重连
};

6.4 集群推送方案

在集群环境下,用户连接可能在不同节点,需要跨节点推送:

方案:Redis Pub/Sub
Redis
消息中心集群
业务服务
推送给用户B
查Redis:用户B在节点2
广播消息
推送
订单服务
节点1

用户A连接
节点2

用户B连接
节点3

用户C连接
Pub/Sub
用户B

实现代码

java 复制代码
@Component
public class ClusterPushService {

    @Autowired
    private WebSocketConnectionManager wsManager;

    @Autowired
    private SseConnectionManager sseManager;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private String nodeId;

    /**
     * 推送消息给指定用户(支持集群)
     */
    public void pushToUser(Long userId, String message, String channelType) {
        // 1. 查询用户连接所在节点
        String key = "push:connection:" + channelType.toLowerCase() + ":" + userId;
        String targetNodeId = redisTemplate.opsForValue().get(key);

        if (targetNodeId == null) {
            log.warn("用户未在线, userId={}", userId);
            return;
        }

        // 2. 如果在当前节点,直接推送
        if (nodeId.equals(targetNodeId)) {
            boolean success = "WEBSOCKET".equals(channelType)
                ? wsManager.push(userId, message)
                : sseManager.push(userId, "message", message);

            log.info("本地推送{}, userId={}, success={}", channelType, userId, success);
            return;
        }

        // 3. 如果在其他节点,通过Redis Pub/Sub转发
        PushMessage pushMsg = new PushMessage(userId, message, channelType, targetNodeId);
        redisTemplate.convertAndSend("channel:push", JSON.toJSONString(pushMsg));

        log.info("跨节点推送{}, userId={}, targetNode={}", channelType, userId, targetNodeId);
    }
}

/**
 * Redis消息监听器
 */
@Component
public class PushMessageListener implements MessageListener {

    @Autowired
    private WebSocketConnectionManager wsManager;

    @Autowired
    private SseConnectionManager sseManager;

    @Autowired
    private String nodeId;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String content = new String(message.getBody(), StandardCharsets.UTF_8);
        PushMessage pushMsg = JSON.parseObject(content, PushMessage.class);

        // 只处理发给当前节点的消息
        if (!nodeId.equals(pushMsg.getTargetNodeId())) {
            return;
        }

        // 推送消息
        if ("WEBSOCKET".equals(pushMsg.getChannelType())) {
            wsManager.push(pushMsg.getUserId(), pushMsg.getMessage());
        } else if ("SSE".equals(pushMsg.getChannelType())) {
            sseManager.push(pushMsg.getUserId(), "message", pushMsg.getMessage());
        }
    }
}

七、核心功能详解

7.1 消息发送完整流程

数据库 渠道提供者 渠道路由 RocketMQ 发送服务 消息API 业务服务 数据库 渠道提供者 渠道路由 RocketMQ 发送服务 消息API 业务服务 异步处理 loop [多渠道并发] 1. 发送消息请求 2. 参数验证 3. 加载模板 4. 渲染内容(替换变量) 5. 创建消息记录 6. 发送到消息队列 7. 返回消息ID 8. 返回成功 9. 消费消息 10. 选择渠道 11. 调用渠道发送 12. 记录发送明细 13. 更新消息状态

代码实现

java 复制代码
@Service
public class MessageSendServiceImpl implements MessageSendService {

    @Autowired
    private MessageTemplateService templateService;

    @Autowired
    private MessageRecordService recordService;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 发送消息
     */
    @Transactional(rollbackFor = Exception.class)
    @Override
    public Long send(MessageSendRequest request) {
        // 1. 参数验证
        validateRequest(request);

        // 2. 加载模板
        MessageTemplate template = templateService.findByCode(request.getTemplateCode());
        if (template == null) {
            throw new BusinessException("模板不存在: " + request.getTemplateCode());
        }

        // 3. 渲染内容
        Map<String, String> contents = renderContents(template, request.getVariables());

        // 4. 创建消息记录
        MessageRecord record = new MessageRecord();
        record.setTemplateCode(request.getTemplateCode());
        record.setMessageType(template.getMessageType());
        record.setBusinessType(template.getBusinessType());
        record.setReceiverId(request.getReceiverId());
        record.setChannels(String.join(",", request.getChannels()));
        record.setVariables(JSON.toJSONString(request.getVariables()));
        record.setStatus(MessageStatus.PENDING.getCode());
        record.setPriority(request.getPriority());
        record.setExpireTime(calculateExpireTime(request));

        recordService.save(record);

        // 5. 发送到消息队列
        MessageDispatchEvent event = new MessageDispatchEvent();
        event.setRecordId(record.getId());
        event.setChannels(request.getChannels());
        event.setContents(contents);
        event.setReceiver(request.getReceiver());

        rocketMQTemplate.asyncSend("message-dispatch", event, new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {
                log.info("消息入队成功, recordId={}", record.getId());
            }

            @Override
            public void onException(Throwable e) {
                log.error("消息入队失败, recordId=" + record.getId(), e);
                // 更新消息状态为失败
                recordService.updateStatus(record.getId(), MessageStatus.FAILED);
            }
        });

        return record.getId();
    }

    /**
     * 渲染各渠道内容
     */
    private Map<String, String> renderContents(MessageTemplate template,
                                               Map<String, Object> variables) {
        Map<String, String> contents = new HashMap<>();

        // 站内信内容
        if (StringUtils.isNotBlank(template.getContentInApp())) {
            contents.put("IN_APP", renderTemplate(template.getContentInApp(), variables));
        }

        // 短信内容
        if (StringUtils.isNotBlank(template.getContentSms())) {
            contents.put("SMS", renderTemplate(template.getContentSms(), variables));
        }

        // 邮件内容
        if (StringUtils.isNotBlank(template.getContentEmail())) {
            contents.put("EMAIL", renderTemplate(template.getContentEmail(), variables));
        }

        // APP推送内容
        if (StringUtils.isNotBlank(template.getContentPush())) {
            contents.put("PUSH", renderTemplate(template.getContentPush(), variables));
        }

        return contents;
    }

    /**
     * 模板渲染(使用Freemarker或简单替换)
     */
    private String renderTemplate(String template, Map<String, Object> variables) {
        String result = template;
        for (Map.Entry<String, Object> entry : variables.entrySet()) {
            String placeholder = "${" + entry.getKey() + "}";
            result = result.replace(placeholder, String.valueOf(entry.getValue()));
        }
        return result;
    }
}

7.2 消息重试机制

重试策略:指数退避(Exponential Backoff)

复制代码
第1次重试: 立即
第2次重试: 1分钟后
第3次重试: 5分钟后
第4次重试: 15分钟后
超过最大次数: 标记为失败

实现代码

java 复制代码
@Component
public class MessageRetryScheduler {

    @Autowired
    private MessageRecordService recordService;

    @Autowired
    private ChannelDispatcher dispatcher;

    /**
     * 定时扫描需要重试的消息(每分钟执行)
     */
    @Scheduled(cron = "0 * * * * ?")
    public void retryFailedMessages() {
        // 1. 查询需要重试的消息
        LocalDateTime now = LocalDateTime.now();
        List<MessageRecord> records = recordService.findRetryable(now);

        if (records.isEmpty()) {
            return;
        }

        log.info("开始重试失败消息, count={}", records.size());

        // 2. 逐条重试
        for (MessageRecord record : records) {
            try {
                // 增加重试次数
                record.setRetryCount(record.getRetryCount() + 1);

                // 计算下次重试时间
                LocalDateTime nextRetryTime = calculateNextRetryTime(
                    record.getRetryCount()
                );
                record.setNextRetryTime(nextRetryTime);

                // 执行重试
                boolean success = dispatcher.dispatch(record);

                if (success) {
                    record.setStatus(MessageStatus.SUCCESS.getCode());
                    log.info("消息重试成功, recordId={}", record.getId());
                } else if (record.getRetryCount() >= record.getMaxRetry()) {
                    // 超过最大重试次数
                    record.setStatus(MessageStatus.FAILED.getCode());
                    log.warn("消息重试失败,已达最大次数, recordId={}", record.getId());
                } else {
                    // 继续等待下次重试
                    record.setStatus(MessageStatus.RETRY.getCode());
                }

                recordService.updateById(record);

            } catch (Exception e) {
                log.error("消息重试异常, recordId=" + record.getId(), e);
            }
        }
    }

    /**
     * 计算下次重试时间(指数退避)
     */
    private LocalDateTime calculateNextRetryTime(int retryCount) {
        int[] delays = {0, 1, 5, 15, 30};  // 分钟
        int delayMinutes = delays[Math.min(retryCount, delays.length - 1)];
        return LocalDateTime.now().plusMinutes(delayMinutes);
    }
}

7.3 群体推送实现

群体推送用于向大量用户发送消息(如活动通知、系统公告):

流程
小于1000
大于1000
创建群体推送任务
查询用户群成员
成员数量检查
直接推送
分批推送
创建消息记录
每批1000条
发送到消息队列
更新推送统计

实现代码

java 复制代码
@Service
public class GroupPushServiceImpl implements GroupPushService {

    @Autowired
    private UserGroupService userGroupService;

    @Autowired
    private MessageSendService messageSendService;

    @Autowired
    private PushRecordService pushRecordService;

    /**
     * 群体推送
     */
    @Transactional(rollbackFor = Exception.class)
    @Override
    public Long groupPush(GroupPushRequest request) {
        // 1. 创建推送记录
        PushRecord record = new PushRecord();
        record.setGroupId(request.getGroupId());
        record.setTemplateCode(request.getTemplateCode());
        record.setChannels(String.join(",", request.getChannels()));
        record.setVariables(JSON.toJSONString(request.getVariables()));
        record.setStatus(PushStatus.PENDING.getCode());

        pushRecordService.save(record);

        // 2. 查询用户群成员
        List<Long> memberIds = userGroupService.getMemberIds(request.getGroupId());
        record.setTotalCount(memberIds.size());
        pushRecordService.updateById(record);

        // 3. 分批推送(异步)
        CompletableFuture.runAsync(() -> {
            try {
                pushToMembers(record.getId(), memberIds, request);
            } catch (Exception e) {
                log.error("群体推送失败, recordId=" + record.getId(), e);
                pushRecordService.updateStatus(record.getId(), PushStatus.FAILED);
            }
        });

        return record.getId();
    }

    /**
     * 分批推送给成员
     */
    private void pushToMembers(Long recordId, List<Long> memberIds,
                               GroupPushRequest request) {
        int batchSize = 1000;
        int totalBatch = (memberIds.size() + batchSize - 1) / batchSize;
        int successCount = 0;

        for (int i = 0; i < totalBatch; i++) {
            int fromIndex = i * batchSize;
            int toIndex = Math.min(fromIndex + batchSize, memberIds.size());
            List<Long> batchIds = memberIds.subList(fromIndex, toIndex);

            log.info("推送第{}/{}批, size={}", i + 1, totalBatch, batchIds.size());

            // 批量发送消息
            for (Long userId : batchIds) {
                try {
                    MessageSendRequest sendRequest = new MessageSendRequest();
                    sendRequest.setTemplateCode(request.getTemplateCode());
                    sendRequest.setReceiverId(userId);
                    sendRequest.setChannels(request.getChannels());
                    sendRequest.setVariables(request.getVariables());

                    messageSendService.send(sendRequest);
                    successCount++;

                } catch (Exception e) {
                    log.error("推送失败, userId=" + userId, e);
                }
            }

            // 更新推送进度
            pushRecordService.updateProgress(recordId, successCount);
        }

        // 更新最终状态
        pushRecordService.updateStatus(recordId, PushStatus.COMPLETED);
        log.info("群体推送完成, recordId={}, total={}, success={}",
                 recordId, memberIds.size(), successCount);
    }
}

八、高可用与性能优化

8.1 高可用架构

集群部署拓扑
数据层
缓存层
消息队列
应用层(3节点)
接入层
主从复制
主从复制
监控
主从复制
Nginx

负载均衡

IP Hash
消息中心-1

WebSocket/SSE连接
消息中心-2

WebSocket/SSE连接
消息中心-3

WebSocket/SSE连接
RocketMQ-Master
RocketMQ-Slave
Redis-Master
Redis-Slave
Redis-Sentinel
MySQL-Master
MySQL-Slave

各层高可用策略

  1. 接入层:Nginx双活 + Keepalived VIP
  2. 应用层:至少3节点,无状态(连接信息存Redis)
  3. 消息队列:RocketMQ主从部署
  4. 缓存层:Redis Sentinel自动故障转移
  5. 数据层:MySQL主从 + MHA故障切换

8.2 性能优化策略

优化1:消息队列削峰
java 复制代码
// 高并发场景,先入队再异步处理
@Override
public Long send(MessageSendRequest request) {
    // 同步:创建消息记录
    MessageRecord record = createRecord(request);

    // 异步:发送到消息队列
    rocketMQTemplate.asyncSend("message-dispatch", record);

    // 立即返回消息ID
    return record.getId();
}

效果

  • 接口响应时间从 500ms → 50ms
  • 支持 1000+ QPS
优化2:连接预热
java 复制代码
@Component
public class WebSocketPreWarmer implements ApplicationRunner {

    @Autowired
    private WebSocketConnectionManager connectionManager;

    @Override
    public void run(ApplicationArguments args) {
        // 启动时预创建连接池
        log.info("WebSocket连接池预热开始...");
        // 初始化连接管理器资源
        connectionManager.preWarm();
        log.info("WebSocket连接池预热完成");
    }
}
优化3:Redis管道批量操作
java 复制代码
// 批量查询用户连接节点
public Map<Long, String> batchFindNodeIds(List<Long> userIds) {
    List<String> keys = userIds.stream()
        .map(id -> "push:connection:ws:" + id)
        .collect(Collectors.toList());

    // 使用Pipeline批量查询
    List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        for (String key : keys) {
            connection.get(key.getBytes());
        }
        return null;
    });

    // 组装结果
    Map<Long, String> nodeMap = new HashMap<>();
    for (int i = 0; i < userIds.size(); i++) {
        if (results.get(i) != null) {
            nodeMap.put(userIds.get(i), (String) results.get(i));
        }
    }

    return nodeMap;
}

8.3 监控告警

关键指标

类别 指标 阈值 告警级别
在线连接 WebSocket连接数 < 100(异常低) Warning
在线连接 SSE连接数 < 50(异常低) Warning
消息发送 消息发送失败率 > 1% Critical
消息发送 消息发送延迟P99 > 5秒 Warning
队列堆积 RocketMQ堆积消息数 > 10000 Critical
系统资源 Redis内存使用率 > 80% Warning
系统资源 MySQL连接数 > 200 Warning

监控实现(Prometheus + Grafana)

java 复制代码
@Component
public class MetricsCollector {

    @Autowired
    private WebSocketConnectionManager wsManager;

    @Autowired
    private MeterRegistry meterRegistry;

    @PostConstruct
    public void init() {
        // 注册指标:WebSocket在线连接数
        Gauge.builder("message.websocket.connections", wsManager,
                      WebSocketConnectionManager::getOnlineCount)
            .description("WebSocket在线连接数")
            .register(meterRegistry);

        // 注册指标:消息发送成功率
        Counter.builder("message.send.success")
            .description("消息发送成功次数")
            .register(meterRegistry);

        Counter.builder("message.send.failed")
            .description("消息发送失败次数")
            .register(meterRegistry);
    }
}

参考资料

  1. 极光推送官方文档
  2. 阿里云消息服务
  3. RocketMQ官方文档
  4. WebSocket RFC 6455
  5. Server-Sent Events规范
  6. 《深入理解RocketMQ》
相关推荐
DKunYu1 天前
7.SpringCloudConfig
spring cloud·微服务
YDS8291 天前
SpringCloud —— MQ的可靠性保障和延迟消息
后端·spring·spring cloud·rabbitmq
慧一居士1 天前
IntelliJ IDEA中的项目jdk版本、语言级别版本与目标字节码版本配置说明与步骤示例
中间件
CRUD酱1 天前
微服务分模块后怎么跨模块访问资源
java·分布式·微服务·中间件·java-ee
laplace01231 天前
Part3 RAG文档切分
笔记·python·中间件·langchain·rag
爱吃山竹的大肚肚1 天前
Kafka中auto-offset-reset各个选项的作用
java·spring boot·spring·spring cloud
qq_165901691 天前
spring-cloud读取Nacos上的配置
java·spring cloud·springcloud
齐 飞1 天前
Spring Cloud Alibaba快速入门-分布式事务Seata(下)
分布式·spring cloud·微服务
manuel_897571 天前
八 系统架构设计
系统架构