设计一个消息推送系统——亿级用户在线推送的架构演进
设计一个消息推送系统——亿级用户在线推送的架构演进
适读人群:对实时推送、长连接有实践需求的工程师 | 阅读时长:约18分钟 | 核心价值:真实场景下消息推送系统从单机到多机的演进历程与关键决策
从一个真实故障开始说起
三年前我们上线了一个电商 App 的订单状态推送功能,逻辑很简单:订单发货了,推一条"您的订单已发货"通知给用户。
上线第一天没问题。第二天早上 9 点,我们的老板在群里说他的订单发货了,但没收到推送。我们登上去一查,推送服务队列里积压了 70 万条消息,全部延迟超过 20 分钟。
那天正好是 618 大促第一天。订单量是平时的 15 倍。
这件事让我意识到,推送系统看起来不起眼,其实里面的架构挑战一点都不比订单系统少。
这篇文章我把消息推送系统从零开始搭到支撑亿级用户在线的完整演进过程写出来,每个阶段的决策我都会说清楚为什么这么选以及代价是什么。
推送系统的本质问题
在开始设计之前,先把问题抽象清楚。
消息推送系统要解决的核心问题是:服务端主动向客户端投递消息。
这个问题的难点在于:
- 用户连接的服务器是哪台?(路由问题)
- 用户不在线怎么办?(离线推送问题)
- 消息量大时如何处理?(容量问题)
- 消息送达了吗?(可靠性问题)
后面的设计都围绕这四个问题展开。
第一阶段:单机长连接,够用就行
最初我们的用户量只有几万,在线用户同时在线不超过 5000。
选型上直接用 WebSocket,服务端用 Netty 处理连接,连接维护在内存里一个 Map 中:
// 连接管理器(单机版)
public class ConnectionManager {
// key: userId, value: Channel
private final ConcurrentHashMap<String, Channel> connections = new ConcurrentHashMap<>();
public void addConnection(String userId, Channel channel) {
Channel old = connections.put(userId, channel);
if (old != null && old.isActive()) {
old.close(); // 踢掉旧连接(同一用户多端登录处理)
}
}
public void removeConnection(String userId) {
connections.remove(userId);
}
public boolean push(String userId, String message) {
Channel channel = connections.get(userId);
if (channel != null && channel.isActive()) {
channel.writeAndFlush(new TextWebSocketFrame(message));
return true;
}
return false;
}
}这个阶段够简单,能跑,没有任何分布式问题。
代价: 只能单机,无法水平扩展,一台机器宕机所有用户下线。
第二阶段:多机部署,路由问题出现了
用户增长到几十万,单机扛不住了,开始做多机部署。
问题随之出现:用户 A 连在 Server1 上,业务系统要给 A 推消息,但它怎么知道要发到 Server1?
路由方案 A:广播模式
最简单粗暴:把消息广播给所有推送服务节点,每个节点自行判断用户是否在本机上连着,在的话推送,不在的话丢弃。
优点:实现极简,不需要路由表。
缺点:每条消息都要发给所有节点,消息量乘以节点数,浪费严重。节点数越多,浪费越大。
我们最开始就是这么做的,3 个节点的时候还好,后来扩展到 10 个节点,消息量放大 10 倍,MQ 压力扛不住了。
路由方案 B:路由表 + 精准投递
在 Redis 里维护一张路由表:userId -> serverId。
用户建立连接时,写入路由表;用户断线时,清除记录。
业务推消息时,先查路由表,找到用户在哪个节点,再把消息精准投递到那个节点。
// 路由表管理
public class RouteRegistry {
private final RedisTemplate<String, String> redis;
public void register(String userId, String serverId) {
// 设置过期时间,防止节点宕机后路由表残留
redis.opsForValue().set("route:" + userId, serverId, 30, TimeUnit.MINUTES);
}
public String getServerId(String userId) {
return redis.opsForValue().get("route:" + userId);
}
public void unregister(String userId) {
redis.delete("route:" + userId);
}
}节点间通信用 MQ 或者 RPC 都可以,我们用的是 MQ,每个节点订阅自己 serverId 对应的 Topic。
这个方案是正确的方向,但有几个细节坑后面我会说。
第三阶段:离线推送问题
用户不在线时推送失败了,怎么办?
我犯的错:直接丢弃
上线最初,用户不在线,推送失败,日志里记一条 push_failed,消息就丢了。
这个在 C 端 App 是不可接受的。用户虽然不在线,但期望下次打开 App 能看到之前错过的消息。
正确做法:离线消息存储 + 拉取补偿
推送失败时,把消息存到离线消息表里。用户下次上线时,客户端主动拉取离线消息。
public void sendMessage(String userId, Message msg) {
String serverId = routeRegistry.getServerId(userId);
if (serverId == null) {
// 用户不在线,存离线消息
offlineMsgStore.save(userId, msg);
return;
}
// 发到对应节点
boolean delivered = pushToServer(serverId, userId, msg);
if (!delivered) {
// 推送节点上用户可能已断线(路由表延迟更新)
offlineMsgStore.save(userId, msg);
}
}客户端上线时的拉取逻辑:
// 客户端连接成功后,携带上次收到的最大消息 ID
// 服务端返回所有比这个 ID 更大的离线消息
public List<Message> pullOfflineMessages(String userId, long lastMsgId) {
return offlineMsgMapper.findAfter(userId, lastMsgId);
}第四阶段:真实踩坑记录
踩坑一:心跳机制设计错了导致大量幽灵连接
我们的 Netty 服务端配置了 60 秒的读超时:如果 60 秒没收到客户端的数据,服务端主动关闭连接。
客户端的心跳间隔是 30 秒发一次 ping。
理论上没问题,但实际情况是:用户的手机熄屏了,很多系统(尤其是 iOS)会限制后台网络活动,心跳发不出去,超过 60 秒后服务端把连接关了。
但 Redis 里的路由表有 30 分钟过期时间,服务端关了连接却没有同步清除 Redis 路由表(清除路由的代码只在 channelInactive 回调里,而某些异常断线不会触发这个回调)。
结果:Redis 路由表指向这台服务器,服务器上连接已经没了,消息投递失败,再存离线消息。
// 修复:连接关闭时,必须清除路由表
@Override
public void channelInactive(ChannelHandlerContext ctx) {
String userId = getUserIdFromChannel(ctx.channel());
if (userId != null) {
// 双重确认:只有当路由表里这条记录是本机时才删除
// 防止用户已经重连到其他节点时误删新节点的路由
String currentServer = routeRegistry.getServerId(userId);
if (SERVER_ID.equals(currentServer)) {
routeRegistry.unregister(userId);
}
connectionManager.removeConnection(userId);
}
}踩坑二:消息顺序乱了
用户 A 同时收到两条消息:一条是订单发货,一条是优惠券到期。我们用了两个 MQ 的 Partition 并行消费,结果消息到达客户端的顺序是随机的。
有的用户看到的是:优惠券到期 → 订单发货(顺序应该反过来)。
解决方案:对同一用户的消息,强制走同一个 Partition,保证 FIFO 消费:
// 发送消息时,以 userId 为 key,保证同一用户消息有序
kafkaTemplate.send(PUSH_TOPIC, userId, message);踩坑三:大量用户同时上线时的雪崩
某次 App 版本更新,大量用户在凌晨自动更新后同时打开了 App,短时间内几十万连接同时建立,每个连接建立都要查 Redis 离线消息、写路由表,Redis 直接打满了。
根本原因是没有限速和背压机制,连接风暴直接透传到后端。
修复方案:
- 连接建立速率限流,每秒最多接受 N 个新连接
- 离线消息拉取做延迟和随机抖动,分散 Redis 压力
- 路由表写入做批量合并
// 连接建立时,延迟随机时间再拉取离线消息
// 防止同时大量连接同时拉取
int delay = ThreadLocalRandom.current().nextInt(0, 5000);
scheduler.schedule(() -> pullOfflineMessages(userId), delay, TimeUnit.MILLISECONDS);第五阶段:亿级用户的架构形态
真正做到亿级用户时,架构已经完全不一样了。
几个关键设计:
长连接网关和业务逻辑完全分离:网关只负责维护连接,不做任何业务逻辑。业务逻辑在推送调度服务里。
离线消息用 Cassandra:离线消息写多读少,需要按用户ID高效查询,Cassandra 的分区键特性非常适合这个场景,而且写入性能远超 MySQL。
APNs/FCM 兜底:用户真的不在线时(手机关了、App 被 kill),靠自己的 WebSocket 连接是没办法推的,必须借助系统级推送通道。这个是在线推送的兜底,不是替代。
总结
消息推送系统的演进本质上是在不断解决三个矛盾:
- 连接数 vs 服务器资源:长连接很"贵",需要做连接集约
- 实时性 vs 可靠性:越追求实时,确认机制越复杂
- 路由精准性 vs 维护成本:路由表越精准,维护越复杂
每个阶段选择什么方案,取决于当前的规模和团队能力边界。没有最好的方案,只有最适合当前阶段的方案。
