消息推送系统设计
消息推送系统设计
IM 消息、系统通知、AI 流式输出——三种场景,一套底层逻辑,讲透实时消息推送的核心设计
消息推送系统的核心挑战可以用一句话概括:如何把消息可靠地投递到在线或离线的用户? 这道题在微信、钉钉等 IM 系统设计中是必考题,在 AI 流式输出(ChatGPT 打字效果)场景也越来越重要。
一、技术选型:四种实时通信方案
1. 短轮询(Short Polling)
客户端每隔 N 秒向服务器发一次 HTTP 请求,问「有没有新消息?」
优点:实现极简,兼容所有浏览器
缺点:延迟高(最长 N 秒才能收到消息)
大量无效请求(90% 时候没有新消息)
服务器压力大
适用:对实时性要求低的场景(如每 30 秒刷新一次的报表)2. 长轮询(Long Polling)
客户端发送请求,服务器挂起连接直到有新消息或超时,然后返回结果,客户端收到后立即发起新的请求。
优点:延迟较低(有消息时立即返回),实现相对简单
缺点:每次消息都需要重建 HTTP 连接(TCP 握手开销)
服务器需要维护大量挂起的连接,内存压力大
适用:消息频率较低、实时性要求中等的场景3. SSE(Server-Sent Events)
基于 HTTP 的单向推送协议:服务器 → 客户端。连接建立后,服务器可以持续向客户端发送事件流(text/event-stream)。
优点:基于标准 HTTP,天然支持代理和负载均衡
自动重连,浏览器原生支持
实现比 WebSocket 简单
缺点:单向(只能服务端推客户端,客户端需要额外 HTTP 请求发消息)
基于 HTTP/1.1 时,每个浏览器对同一域名最多 6 个连接
适用:AI 流式输出(ChatGPT 打字效果)、新闻动态推送、实时通知ChatGPT 为什么用 SSE? AI 大模型生成回答是流式的(token by token),SSE 天然支持服务端持续推送,且客户端不需要双向通信,非常契合这个场景。
4. WebSocket
全双工持久连接,建立后客户端和服务端可以随时互相发消息,底层是 TCP 长连接。
优点:全双工,延迟最低(< 100ms)
无需重复建立连接,节省握手开销
适合频繁双向通信
缺点:有状态连接,服务器水平扩展复杂(需要解决跨机器路由问题)
部分代理/防火墙需要特殊配置
适用:IM 聊天、在线游戏、协同编辑、实时协作方案对比
| 方案 | 方向 | 延迟 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 单向 | 高(N 秒) | 低 | 低频刷新 |
| 长轮询 | 单向 | 中 | 中 | 中频通知 |
| SSE | 单向(服务→客户端) | 低 | 低 | AI 流式输出、通知 |
| WebSocket | 双向 | 极低 | 高 | IM、实时协作 |
二、整体架构设计(以 IM 消息系统为例)
发送方
↓ 发消息 HTTP/WebSocket
消息服务(Message Service)
↓ 持久化,异步解耦
Kafka(消息持久化,保证不丢)
↓ 消费
推送分发服务(Dispatch Service)
↓ 判断用户在线状态
├─ 在线 → WebSocket 网关 → 直推到接收方
└─ 离线 → 离线消息存 DB → App 打开时拉取 / APNs/FCM 推送三、连接管理:多机器路由问题
WebSocket 是有状态连接,用户 A 的连接在机器 1 上,推送服务需要找到机器 1 才能把消息推出去。
解决方案:Redis 存储用户 → 网关机器的映射
用户连接时:SETEX user:gateway:{userId} 30 {machineIp} (TTL=30s,心跳续期)
推送消息时:先查 Redis 找到目标机器 IP
→ HTTP 调用目标机器的内部接口推送消息
→ 目标机器通过内存中的 WebSocket 连接找到用户,推出消息在线状态判断:
- 在线:Redis 中存在
user:gateway:{userId}key,且心跳续期正常 - 离线:key 不存在(TTL 到期未续期,即连接已断开)
四、消息可靠性保证
消息推送的可靠性要求远高于普通 API——消息丢了用户是无法接受的。
端到端消息状态流转
每条消息有明确的状态机:
SENT(已发送)→ DELIVERED(已送达)→ READ(已读)- 服务端发出后立即设为 SENT
- WebSocket 推送到客户端,客户端 ACK 后更新为 DELIVERED
- 用户打开聊天窗口,客户端发 READ 确认,更新为 READ
消息 ACK 机制
WebSocket 不像 HTTP 有内置的应答机制,需要应用层实现:
服务端发消息时附带 msgId
客户端收到消息后,发送 ACK {msgId} 回服务端
服务端等待 ACK,超时未收到则重发(最多重发 3 次)
客户端收到重复消息,按 msgId 去重(幂等处理)离线消息处理
用户离线时,消息存入离线消息表。用户上线后:
- WebSocket 连接建立时,服务端主动推送未读消息列表
- 客户端按 msgId 顺序拉取消息内容
- 客户端批量 ACK 已拉取的消息
移动端还需接入 APNs(iOS)/ FCM(Android)推送通知,触达离线用户。
五、消息顺序性保证
同一会话的消息必须按发送顺序到达,不能乱序。
Kafka 分区策略:按 conversationId(会话 ID)做 Partition Key,同一会话的所有消息进入同一 Partition,Partition 内消息严格有序。
客户端排序:每条消息携带服务端生成的递增序列号(seq),客户端按 seq 排序展示,即使网络乱序到达也能正确显示。
六、AI 流式输出(SSE 实战)
ChatGPT 的「打字效果」就是 SSE 实现的。Spring Boot 实现 SSE 流式输出:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamChat(
@RequestParam String message) {
return aiClient.streamComplete(message)
.map(token -> ServerSentEvent.<String>builder()
.data(token) // 每个 token 作为一个 SSE 事件推送
.build())
.concatWith(
// 流式结束时发送结束标记
Mono.just(ServerSentEvent.<String>builder()
.event("done")
.data("[DONE]")
.build())
);
}客户端使用 EventSource 接收:
const source = new EventSource('/chat/stream?message=你好')
source.onmessage = (event) => {
if (event.data === '[DONE]') {
source.close()
return
}
// 追加 token 到输出框,实现打字效果
document.getElementById('output').textContent += event.data
}七、面试题精选
Q:WebSocket 和 SSE 如何选择?
核心判断:是否需要双向通信。需要双向(如 IM 聊天,用户发消息、收消息都通过同一连接)→ WebSocket。只需要服务端主动推(如 AI 流式输出、消息通知)→ SSE 更简单,实现成本低,天然兼容 HTTP 基础设施(代理、负载均衡)。
Q:如何解决 WebSocket 跨机器路由问题?
用 Redis 存储 userId → 网关机器 IP 的映射,TTL=30 秒,心跳续期。推送服务收到消息后,先查 Redis 找到用户所在机器,再 HTTP 调用该机器内部接口推送。这样推送服务是无状态的,可以水平扩展;只有 WebSocket 网关是有状态的,但每台机器只管「自己接的连接」,路由逻辑在中间层。
Q:如何保证消息不丢失?
三层保障:① 生产端:消息先写 Kafka(持久化),再推送,Kafka 本身高可靠;② 传输层:应用层 ACK 机制,服务端发出消息后等待客户端 ACK,超时重推(幂等处理重复消息);③ 离线兜底:用户离线时消息写入 DB,上线后主动拉取。三层结合,即使每一层都出现偶发失败,整体消息不丢。
Q:消息推送系统如何支持百万并发连接?
WebSocket 网关水平扩展:单机可维护 10 万并发连接(1GB 内存约可维护 10 万个空闲 WebSocket 连接)。10 台机器可支持 100 万并发。关键优化点:使用 Netty 等异步 NIO 框架代替传统线程模型(每连接一线程扛不住),心跳检测及时清理死连接,连接元数据用 Redis 存储而非本地内存。
知识星球深度内容
完整 WebSocket + SSE 消息推送 Spring Boot 实现、IM 系统大厂面经实录、AI 流式输出完整方案,加入「AI 工程师加速社区」知识星球获取 👉 立即加入
