如何设计一个消息推送系统?

2025年 阅读约 15 分钟 面试指南 · 系统设计

深入解析消息推送系统设计:WebSocket长连接管理、心跳机制(保活+检测)、消息可靠性(ACK+重试+去重)、离线消息存储、百万连接优化(Netty+epoll),附面试模拟问答。

一句话总结

消息推送系统核心:WebSocket 长连接(全双工通信)+ 心跳机制(保活+检测断线)+ ACK 确认(保证消息可靠送达)。架构:接入层(Netty 管理连接)→ 逻辑层(路由、存储)→ 推送层(按用户ID找到连接并推送)。百万连接优化:Netty + epoll(单机可支撑百万连接)、连接路由表(Redis 存储 userID → serverIP 映射)。

初级理解

推送 vs 轮询 vs 长轮询 vs WebSocket

方案原理实时性资源消耗
短轮询客户端定时请求高(大量无效请求)
长轮询客户端请求,服务端hold住直到有数据
WebSocket全双工长连接,双向推送低(一次握手)
# WebSocket 握手(HTTP Upgrade) # 客户端请求 GET /chat HTTP/1.1 Host: server.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== # 服务端响应 HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= # 握手成功后,TCP 连接升级为 WebSocket # 后续通信使用 WebSocket 帧格式(非 HTTP)
一句话总结:WebSocket 全双工长连接,一次握手后双向通信,实时性最高。

中级深入

心跳机制

# 心跳目的 # 1. 保活:防止 NAT/防火墙断开空闲连接 # 2. 检测:及时发现断线,清理无效连接 # 心跳策略 # 客户端:每 30 秒发送 Ping # 服务端:收到 Ping 回复 Pong,超过 90 秒未收到 Ping 断开连接 # 服务端心跳检测 public class HeartBeatHandler { # 定时任务:每 30 秒检查所有连接 @Scheduled(fixedRate = 30000) public void checkHeartBeat() { long now = System.currentTimeMillis(); for (Connection conn : connections) { if (now - conn.getLastHeartBeat() > 90000) { conn.close(); # 90 秒无心跳,断开 } } } }

消息可靠性保证

# 消息可靠性三要素 # 1. ACK 确认:客户端收到消息后回复 ACK # 2. 重试机制:未收到 ACK 则重试(最多 3 次) # 3. 去重:客户端根据消息 ID 去重 # 服务端推送流程 # 1. 发送消息(带 msgId) # 2. 等待 ACK(超时 5 秒) # 3. 收到 ACK → 标记已送达 # 4. 未收到 ACK → 重试(最多 3 次) # 5. 3 次重试失败 → 标记失败,走离线消息 # 离线消息 # 用户不在线 → 消息存入 DB # 用户上线 → 拉取离线消息 → 标记已读
中级要点:心跳保活+检测断线;ACK+重试保证可靠送达;离线消息兜底。

高级拓展

百万连接架构

# 单机瓶颈 # Linux 默认单进程最大文件描述符 1024 # 需要调整:ulimit -n 1000000 # Netty 优化 # 1. 使用 EpollEventLoopGroup(Linux epoll) # 2. Boss 线程 1 个,Worker 线程 = CPU 核数 # 3. 零拷贝(FileRegion) # 4. 内存池(PooledByteBufAllocator) # 集群架构 # 1. 多台接入服务器(Netty) # 2. Redis 存储路由表:userID → serverIP # 3. 推送时查路由表 → 转发到对应服务器 # 4. 服务器间通过 MQ 或 RPC 通信 # 路由表 # Redis Hash: user_route # user:1001 → server:192.168.1.101:8080 # user:1002 → server:192.168.1.102:8080

消息时序性保证

# 问题:分布式环境下消息可能乱序 # 方案: # 1. 消息带序列号(seqId) # 2. 客户端按 seqId 排序 # 3. 同一用户的消息路由到同一台服务器(一致性哈希) # 4. 或使用单线程消费(保证顺序)

实战场景

场景:站内信推送

# 推送流程 # 1. 业务服务发送推送请求 # 2. 推送服务查路由表(userID → serverIP) # 3. 转发到对应接入服务器 # 4. 接入服务器找到 WebSocket 连接 → 推送 # 推送接口 POST /push { "userId": 1001, "msgId": "msg_001", "content": "您有一条新消息", "type": "notification" } # 接入服务器处理 public void push(PushMessage msg) { Connection conn = connectionMap.get(msg.getUserId()); if (conn != null && conn.isActive()) { conn.send(msg); # 在线 → 直接推送 } else { offlineStore.save(msg); # 离线 → 存储 } }

面试模拟

面试官:消息推送系统怎么设计?

你:WebSocket 长连接 + Netty 接入层。心跳保活(30s Ping,90s 超时断开)。消息可靠性:ACK + 重试(3次)+ 离线存储。集群路由:Redis 存 userID → serverIP 映射。百万连接:Netty epoll + 调整文件描述符限制。

面试官:怎么保证消息不丢失?

你:1)ACK 确认机制:客户端收到后回复 ACK,服务端超时重试;2)离线消息:用户不在线时存 DB,上线后拉取;3)消息去重:客户端根据 msgId 去重;4)服务端持久化:消息先落盘再推送。