海量推送系统
请使用电脑访问
https://myblog-ad4.pages.dev推送指的是服务端把消息主动、实时地发送到客户端。
早期推送更多属于即时通信(IM)领域。移动互联网普及后,大量用户会长期在线,很多应用都需要实时触达用户,例如:
社交互动:关注、私聊、回复、点赞、直播礼物 |
本章希望设计一个通用推送系统,支持各种业务方主动向在线用户发送事件消息,并能服务百万级、千万级甚至上亿级在线用户,同时保证消息实时到达、性能较高,并具备高可用性。
6.1 分布式长连接服务的技术要素分析
普通客户端与服务器交互时,通常是客户端主动发起请求,服务器被动应答。
推送场景不同:服务器需要主动向客户端发送消息,所以客户端与服务器之间必须保持长连接。
推送系统的核心 = 长连接服务 |
后续所有设计,都会围绕长连接服务展开。
6.1.1 WebSocket 协议简介
客户端通常通过 HTTP 与远程服务器通信。
HTTP 采用请求/响应模型,本身是一种无状态、无连接、单向的应用层协议:
客户端主动请求 |
原生 HTTP 无法让服务器主动向客户端发送数据。
如果服务器有新消息要通知客户端,只能让客户端定期轮询服务器。例如每隔几秒请求一次,检查是否有新消息。
轮询方式的问题是:
1. 效率低 |
WebSocket 是一种基于 TCP 的全双工通信协议。客户端和服务器建立连接后,双方都可以主动向对方推送数据。
如下图所示:使用 HTTP 轮询需要不断建立请求、等待响应;WebSocket 只需建立一次连接就能长期保持连接状态。因此,WebSocket 比轮询更适合推送场景。

在客户端侧,可以开发一个基于 WebSocket 的长连接通信 SDK。客户端通过这个 SDK 创建专门用于接收服务端推送消息的通道,各类业务推送消息都从这个通道下发。
6.1.2 长连接服务器
客户端通过 WebSocket 与服务器建立长连接。站在服务器视角,它需要是一台支持高并发、能维护和管理大量长连接的服务器。
常见高并发服务器模型如下。
1. accept + fork 多进程模型
当客户端连接到来时,服务器分配一个子进程处理这个连接的读/写请求,即一个连接对应一个进程。
这种模型简单直观,但大量连接会生成等量进程,服务器资源消耗很大。
2. accept + thread 多线程模型
这是对多进程模型的优化。当客户端连接到来时,服务器分配一个线程处理这个连接的读/写请求,即一个连接对应一个线程。
线程比进程更节省资源,但当连接量很大时,仍然会带来较大的服务器资源消耗。早期 Tomcat 曾使用过这种设计。
3. 单线程多路复用模型
单线程使用 epoll 监听和管理所有连接:
没有事件到来:线程被 epoll_wait 阻塞 |
这种方式也称为 Reactor 模式,很适合大量连接的 I/O 密集型场景。
它的限制是只能使用单核 CPU。如果 handler 处理耗时过长,会影响服务器整体响应时间。
典型代表:Redis。
4. 多进程多路复用模型
这是对单进程多路复用模型的优化。
它可以充分利用多核能力,也能对 handler 的耗时有一定容忍性。
典型代表:Nginx。
5. 多线程多路复用模型
它与多进程多路复用模型类似,只是使用多线程代替多进程。
典型代表:Memcached。
推送服务器适合哪类模型?
推送服务器的主要职责是:
1. 与客户端建立长连接 |
它是一台相对纯粹的消息转发服务器,属于典型 I/O 密集型应用。
因此,只要是多路复用类型的模型,基本都适合推送服务器。
不同模型的取舍:
单线程多路复用: |
由于推送服务器逻辑较轻量,这些模型通常都能较好地支持数十万个长连接并发访问。
在这我们可以先暂时把这台负责建立连接和推送消息的单体长连接服务器称为:pusher
6.1.3 分布式推送服务器
单台 pusher 服务器虽然可以承载数十万个客户端设备的长连接,但实际在线设备可能达到:数百万、数千万、上亿
单个 pusher 节点无法满足这种规模。
另外,单体服务器天然不具备高可用性和可扩展性,所以需要把多个 pusher 节点组成分布式 pusher 集群对外提供服务。
分布式 pusher 集群需要解决的问题
书中列出了 4 个关键问题。
问题 1: |
问题 1:服务发现
问题 1 本质是服务发现问题。
可以使用 ZooKeeper 或 etcd 等分布式一致性中间件维护 pusher 节点列表。
基本方式是:
新增 pusher 节点 -> 向 ZooKeeper 注册地址 |
这样就可以知道当前有哪些 pusher 节点正在工作。
问题 2 和问题 3:路由问题
问题 2 和问题 3 的本质相同:
设备连接 与 pusher 节点之间的关联规则是什么? |
这套关联规则有两个作用:
建立连接时:决定设备连接到哪个 pusher 节点 |
这就是路由问题,更通俗的叫法是负载均衡。
问题 4:长连接迁移
推送系统不同于分布式存储系统。
分布式存储扩容时,可以迁移数据;但长连接本身不是实体数据,不能直接跨机器迁移。
长连接无法迁移 |
为了不让用户感知到重连,客户端需要充分权衡影响面后再做决定。
6.1.4 路由算法
推送系统中有两处需要使用路由算法。
1. 客户端设备发起连接请求时: |
路由算法选型要尽量保证用户设备连接均匀分配到各个 pusher 节点上。
不能出现这种情况:
有的 pusher 节点连接过多,接近饱和 |
同时,系统还必须知道:
device_id -> pusher 节点地址 |
因为只有知道目标设备在哪个 pusher 节点上,才能把待推送消息转发到正确节点,再由该 pusher 节点完成下行消息发送。
常见路由算法
常见路由算法包括:
Random:随机调度 |
这些算法都可以较好地保证连接均匀分布在 pusher 集群中。
但它们在获取 device_id -> pusher 节点地址 关联关系时,方式差异较大。
Random / Round-Robin 算法
Random 和 Round-Robin 没有明确的数据映射规则。
也就是说,无法通过固定公式直接算出某个 device_id 对应哪个 pusher 节点。
因此,需要在设备与 pusher 节点建立连接时,把两者关联关系保存到外部存储系统中。
推荐使用 Redis:
Key:device_id |
使用 Redis 的原因:
1. Redis 读写性能足够高,可以应对海量推送时的路由查询 |
一致性 Hash / 其他 Hash 算法
Hash 类算法有明确的数学公式指导。
也就是说:
device_id -> 通过 Hash 计算 -> pusher 节点地址 |
因此,不需要额外使用外部存储系统保存 device_id 与 pusher 节点地址的关联关系。
路由算法没有绝对好坏
一致性 Hash 看起来可以省掉 Redis,但它未必就是最合适的选择。
需要考虑 pusher 集群节点变化的情况。
如果 pusher 集群压力较大,需要新增一个 pusher 节点 p1,那么在一致性哈希环上,与它相邻的 pusher 节点 p0 上大约一半 device_id 都要重新指向 p1。
这些 device_id 原本与 p0 建立的长连接必须断开,再重新连接到 p1。
也就是说:**一致性 Hash 扩容时,会带来较多长连接重连工作 **
而Round-Robin算法对集群的扩容比较友好,当向集群中新增一个 pusher 节点 p1 时,全部存留的长连接都不需要进行迁移,所有的 device_id 与 pusher 节点地址的关联关系依然是有效的。
所以路由算法没有绝对好坏,需要结合扩容、连接稳定性、路由查询成本等因素做权衡。
谁负责计算路由?
路由算法确定后,还要明确由谁负责计算路由和转发请求。
对于客户端建立连接请求:
客户端 -> Nginx -> pusher |
可以让客户端统一指向代理层服务器 Nginx,由 Nginx 通过路由算法选择一个 pusher 节点与设备建立连接。
对于后端消息的推送请求,可以抽象出一个下行消息到 pusher 集群的代理网关 Push-gateway,由它根据消息目标设备计算路由,并把消息转发到对应的 pusher 节点。
后端应用服务 -> Push-gateway -> pusher |
本节总结
推送系统的核心是长连接服务。 |
WebSocket 适合推送场景,因为它基于 TCP,支持全双工通信,建立一次连接后双方都可以主动发送数据。
pusher 是负责维护长连接和转发消息的核心节点,它属于典型 I/O 密集型服务,适合使用多路复用类服务器模型。
单台 pusher 无法支撑百万级、千万级乃至上亿级在线设备,因此需要分布式 pusher 集群。
分布式 pusher 集群最关键的是路由:
建立连接时:路由决定设备连到哪个 pusher |
路由算法没有绝对好坏,需要在连接均衡、查询成本、扩容影响之间做取舍。
6.2 海量推送系统设计
6.1 节介绍了推送系统涉及的关键技术,本节开始设计一个完整的海量推送系统。
6.2.1 整体架构设计
推送系统整体架构如下图所示。

- Nginx 作为客户端设备与推送系统交互的代理,负责客户端建立连接的负载均衡与消息的最终下发;
- 每个 pusher 节点都负责与不同的客户端建立长连接,是消息的核心推送者;
- ZooKeeper/etcd 负责 Nginx 请求 pusher 集群时的服务发现,为 Nginx 转发连接请求时给出当前可用的 pusher 节点列表,进而帮助客户端连接到合适的 pusher 节点;
- Redis 集群负责保存用户设备与 pusher 节点的关联关系(如果采用 Round-Robin 等无数据规则的路由算法);
- Push-gateway 作为下行消息到 pusher 集群的代理网关,负责后端应用服务与推送系统之间的交互,包括按照单点消息、多点消息以及全局消息的方式进行消息的路由转发,是后端应用服务与推送系统之间的唯一代理;
- 消息队列负责消息的异步发送。
6.2.2 长连接的建立过程
客户端与推送系统建立长连接的过程如下图所示。

(1)当客户端进行登录/启动时,客户端长连接通信 SDK 使用 WebSocket 与后端尝试建立连接,在建立连接时可以携带若干客户端基本信息,如用户 ID、设备 device_id、设备版本号等。
(2)Nginx 收到客户端的建立连接请求,使用所设定的路由算法,选择一个 pusher 节点与其建立长连接。
(3)如果推送系统采用了 Round-Robin 等无数据规则的路由算法,则需要同时将 device_id 与 pusher 节点地址的关联关系以 Key-Value 的形式保存到 Redis 集群中。存储数据结构类型可以采用 Key-Value 的形式,其中 Key 是用户设备 device_id,Value 是对应的 pusher 节点的 IP:port 地址。
(4)pusher 节点与客户端建立长连接后,将 device_id 与连接 Socket 文件描述符 fd 的关联关系保存到本地内存中。
(5)客户端周期性地与 pusher 节点在长连接上确认心跳,如果 pusher 节点在所设定的时间内没有收到心跳,则认为客户端长连接已断开,关闭其连接。
6.2.3 消息格式设计
pusher 节点发送到客户端的消息格式可以设计为 PushMessage。
下表中列出了最重要的消息字段。
| 字段名 | 类型 | 含义 | 用途 |
|---|---|---|---|
| uuid | uint64 | 消息的唯一 ID | 客户端使用这个唯一 ID 识别重复的消息 |
| target_did | uint64 | 消息的目标 device_id | 客户端用它来检查消息是否是发给自己的 |
| unique_name | string | 消息的业务专用名称,指明 payload | 客户端用它来识别此推送的消息的真正消息体 |
| payload | string | 序列化的消息内容 | 真正的消息体 |
| encode_type | int | 序列化方法 | 客户端根据序列化方法,正确解析 payload |
uuid:每条消息都有自己独有的唯一 ID,客户端可以使用这个唯一 ID 判断消息是否是重复下发的,如果是则丢弃此消息。此外,唯一 ID 也可以协助服务端和客户端的工程师进行消息触达的测试与调试。
target_did:消息的目标 device_id,客户端可以将目标 device_id 与本地 device_id 进行对比,如果不一致则丢弃消息。这是为了防止推送系统在遇到某些异常时将消息错发到其他设备。
unique_name:用于告知客户端此消息中的 payload 数据实际上是哪段业务逻辑所需要的,以便客户端能把消息交给对应的 handler 来处理。
payload:真正的消息体,不同业务场景的推送数据有不同的格式,将推送数据序列化到 payload。
encode_type:指明 payload 的编码方式,用于告知客户端应该使用何种方式对 payload 进行解析。
评论通知示例
假设某个用户对你的文章发表评论,客户端需要弹出通知:
A 对你的文章 B 发表了评论:xxx |
文章评论模块的服务端和客户端可以共同约定如下通知格式(假设使用 protobuf):
message CommentNotify { |
服务端处理方式:
1. 使用 protobuf 将 CommentNotify 序列化 |
客户端处理方式:
1. 根据 unique_name 识别这是评论通知 |
6.2.4 消息推送接口
推送系统需要暴露给后端应用服务一个消息推送接口。
接口的核心结构如下。
enum PushType { // 消息类型 |
业务服务端先构造 PushMessageReq。
不同消息类型的 TargetDeviceIds 使用方式:
单点消息:列表中只有一个 device_id |
业务服务端构造请求后,调用推送系统的 PushMessage 接口。
如果推送成功,返回 PushMessageResp 表示成功;如果失败,则返回错误码和错误信息。
6.2.5 单点消息推送的细节
将一条单点消息从服务端推送到客户端的完整过程如下所述,如下图所示。
(1)单点消息携带推送的消息体 payload、推送的目标 device_id,将推送消息请求发送到 Push-gateway。
(2)Push-gateway 从 Redis 集群中查询消息目标 device_id 对应的 pusher 节点地址。
(3)如果无须访问 Redis,则 Push-gateway 使用与建立长连接时相同的路由算法,计算出消息目标 device_id 对应的 pusher 节点地址。
(4)Push-gateway 将消息转发到计算出的 pusher 节点。
(5)pusher 节点在本地内存中查询长连接文件描述符 fd。
(6)pusher 将消息发送到 fd 上,下行消息推送完成。

6.2.6 全局消息推送的细节
对于要发送给全部在线用户的推送的消息,我们可以让 Push-gateway 将推送的消息广播到所有 pusher 节点,由每个 pusher 节点将消息数据写入本地每个连接中。
这里涉及两个“写放大”,如下图所示,其中:
- Push-gateway 需要轮询所有 M 个 pusher 节点并进行消息的转发,写放大 M 倍;
- 假设一个 pusher 节点本地维护 N 个长连接,当它收到一条全局的推送的消息时,需要把消息发送到所有长连接的发送缓冲区,网络带宽会瞬间增大,写放大 N 倍。

通常而言,一个被部署在商业级服务器上的长连接推送服务节点经过高性能设计,可以同时承载十多万个乃至数十万个长连接。也就是说,仅 1000 个 pusher 节点就可以管理承载上亿个在线用户的长连接。
如果有条件,部署 pusher 节点的服务器性能较高、网卡读/写快,那么由于 pusher 节点的单机长连接数量可以很大,pusher 节点不会很多,所以 M × N 倍的写放大压力很容易被接受。
但是如果条件有限,网卡的性能一般,甚至云服务器的性能较差,该怎么办?
我们可以换一种思路来解决这个问题。全局消息一般是系统推送的消息,这种消息对于同时到达全部用户的诉求不高,所以可以让一部分用户先收到消息,另一部分用户稍后收到消息。于是,我们可以尝试依次分批发送的思路。
广播的批次 M 可以根据服务器的性能而定,假设批次为 100。
(1)在 Push-gateway 上通过 mod 操作将发往设备 X 的消息分别归到批次 Y,即将 device_id%100 作为批次顺序。
(2)Push-gateway 与每个 pusher 节点都建立一个广播消息队列通道,并在消息体 PushMessage 结构中将新增字段 hash_id 作为批次顺序 ID;Push-gateway 收到全局消息后,将消息分为 100 次向消息队列投递:
- 第 1 次全局消息投递,设置 hash_id=0,发送到消息队列;
- 第 2 次全局消息投递,设置 hash_id=1,发送到消息队列;
- ……
- 第 100 次全局消息投递,设置 hash_id=99,发送到消息队列。
(3)每个 pusher 节点收到消息后,都根据 hash_id 向本地维护的满足 device_id%100 的长连接发送这条消息。
这样一来,通过将全局消息分 M 次分批逐步发送,可以使得原来的全局消息推送开销下降到 1/ M。
6.2.7 多点消息推送的细节
多点消息指一条推送消息有多个目标 device_id。
推送系统收到多点消息后,需要找到每个目标 device_id 对应的 pusher 节点地址,然后向这些 pusher 节点转发消息。
这里的重点是:
如何高效找到所有目标 device_id 对应的 pusher 节点列表 |
Hash 类路由算法
如果 pusher 与客户端建立连接时使用一致性 Hash 等有数据规则的路由算法,Push-gateway 处理多点消息时会比较简单。
无论目标 device_id 有多少,都可以在本地内存中快速计算出对应的 pusher 节点地址,然后进行消息转发。
Round-Robin 类路由算法
如果使用 Round-Robin 等无数据规则的路由算法,那么 device_id 与 pusher 节点地址的对应关系保存在 Redis 中。
此时可以使用 Redis MGET 一次性获取多个 device_id 对应的 pusher 节点地址。
但 Redis MGET 是一个危险命令。
如果目标 device_id 较少,例如少于 50 个,使用 MGET 问题不大。
如果目标 device_id 很多,MGET 可能带来风险。
Redis MGET 的风险
风险 1:Redis 服务器内存压力。
一次性获取过多 Key |
风险 2:Redis Proxy 资源压力。
很多互联网公司的 Redis 架构类似 Codis,由 Proxy 对 Redis 做集群分片管理。
为了满足 MGET,Proxy 需要启动多个线程向相关 Redis 分片分发请求。
一次性获取过多 Key |
目标 device_id 较多时的处理方式
如果多点消息涉及的目标 device_id 较多,就不适合直接从 Redis 中批量获取 pusher 节点地址。
此时可以换一个思路:
目标 device_id 很多 |
每个 pusher 节点收到消息后:
如果本地有相关 device_id 长连接 -> 推送消息 |
多点消息处理逻辑总结
多点消息的处理逻辑取决于长连接使用的路由算法。
Hash 类路由算法:无论目标
device_id有多少,Push-gateway 都可以直接计算出 pusher 节点地址,然后进行消息的转发。Round-Robin 类路由算法:进一步检查目标
device_id的数量是否达到阈值(如 50 个)。如果未达到阈值,则 Push-gateway 通过 Redis MGET 获取相关的 pusher 节点地址列表,然后进行消息的转发。
如果超过阈值,则 Push-gateway 向全部 pusher 节点广播此消息,由 pusher 节点自己判断是推送消息还是丢弃消息。
6.2.8 pusher 平滑升级的问题
pusher 服务器本身也是一个服务,会涉及系统开发、迭代升级和 Bug 修复。
普通服务升级时,可以让旧进程退出,再启动新进程。但 pusher 是长连接服务器,如果 pusher 进程退出,会导致所有长连接被关闭。
例如,一个 pusher 节点保持了 10 万个客户端连接。升级时这些连接全部断开,客户端会几乎同时重新建立连接,推送系统会收到 10 万 QPS 的瞬时连接请求。
这种瞬时激增的连接请求可能击垮推送系统,导致系统无法创建长连接。
一种保护方式是:
在建立连接入口 Nginx 增加限流 |
但更好的方式是提供 pusher 节点的平滑升级,让已经建立的长连接尽量保持。
平滑升级的成熟方案
长连接服务平滑重启是成熟方案。
业界高性能服务器都有平滑升级的功能,例如:Nginx、Service Mesh 的 MOSN Sidecar。这些 TCP 服务器平滑升级通常依赖三类关键技术。
- 信号技术:使用自定义信号如 SIGUSR2 作为进程升级事件,而非直接杀死进程。
- fork + exec 技术:父进程调用 fork() 创建子进程,子进程调用 exec() 载入最新程序二进制文件,原父进程的文件打开信息会被自动继承下来。
- UnixSocket 协议:支持在进程间传输文件描述符。
平滑升级的基本原理
平滑升级可以理解为:
旧进程收到升级信号 |
重点是:
连接没有被粗暴关闭 |
平滑升级流程
系统准备升级服务,向父进程发送
SIGUSR2信号。父进程收到
SIGUSR2信号,准备更新服务。SIGUSR2信号处理函数调用fork()创建子进程,子进程调用exec()载入最新二进制文件,并携带约定参数,表示新进程是通过重启加载的。父进程不再对
listen_fd读取新连接,也不再对客户端连接读取数据,即停止一切 Socket 处理工作。父进程启动一个 UnixSocket,并调用
sendmsg()向子进程发送listen_fd和全部客户端连接 fd,直到全部成功才停止。子进程通过运行参数判断自己是否通过重启启动。如果是,就不主动创建
listen_fd,而是创建与父进程同路径的 UnixSocket,用于接收父进程传输的信息。子进程收到
listen_fd后,直接调用accept()继续接收新连接请求,此时新进程正式可以对外提供服务。子进程持续收到全部客户端连接 fd,为每个连接创建 handler 继续处理读写请求,此时新进程接管旧进程的存量客户端连接。
子进程通知父进程终止,新进程最终替换旧进程,升级过程完成。
6.2.9 pusher 扩容的问题
pusher 服务除了升级,还会因为请求量增长而需要扩容。
但长连接服务是典型有状态服务,而且长连接不是实体数据,不能直接迁移,所以扩容时有很多问题需要处理。
扩容的目标是: 新增 pusher 节点后,希望它能尽快分担已有 pusher 节点的压力
Random / Round-Robin 扩容问题
如果采用 Random 或 Round-Robin 等路由算法,新增 pusher 节点后,并不能很有效地分担已有节点的连接压力。
以 Round-Robin 为例。
假设原来有 5 个 pusher 节点:
p1 p2 p3 p4 p5 |
现在系统压力很大,新增 pusher 节点 p6。
扩容后的长连接数量变成:
p1 p2 p3 p4 p5 p6 |
接下来又有 60 个新客户端连接到来。
在 Round-Robin 下,6 个节点的连接数量会变成:
p1 p2 p3 p4 p5 p6 |
可以看到:
存量连接保持了 |
这说明在集群扩容时,普通 Round-Robin 不能满足“新增节点尽快均摊新连接”的要求。
解决方案:加权 Round-Robin
解决方法是采用加权式 Round-Robin。
为每个 pusher 节点引入权重:
权重高的 pusher 节点 |
对于新加入的 pusher 节点,可以在最初一段时间设置较高权重。
当新节点达到与其他 pusher 节点相似的连接容量后,再把权重下调到平均水平。
这样新节点加入集群后,就能快速分担后续新连接压力,并较快与其他节点在长连接数量上持平。
一致性 Hash 扩容问题
如果采用一致性 Hash,会遇到另一个问题:连接失效。
当新 pusher 节点加入一致性哈希环时,会使环上相邻 pusher 节点的大量长连接重新指向新节点。这个比例大约是一半,问题是:
这些连接实际仍然在原 pusher 节点上 |
因此,这些连接会全部失效,需要断开并重新连接。
这会带来大量客户端同时重连,造成瞬时连接请求压力。
一致性 Hash 的保护方式
为了防止大量客户端同时重新连接,可以使用两类方式。
第一种方式:限流。
通过限流保护推送系统 |
第二种方式:逐步断开并重新连接。新 pusher 节点加入后,相邻 pusher 节点的受影响的长连接并不全部同时断开重新连接,而是逐步断开重新连接,拉长重新连接的时间线,减少瞬时请求量。
6.3 本章小结
本章主要讲解了如何设计一个海量推送系统,以及设计推送系统时需要注意的技术细节。
首先,客户端与 pusher 节点建立连接时使用的路由算法非常重要。
它会直接决定:
1. 下行消息如何转发 |
其次,单点消息、多点消息、全局消息的推送逻辑并不相同。
单点消息:找到一个目标 device_id 对应的 pusher |
最后,pusher 服务是长连接服务,不能像普通无状态服务那样简单重启或扩容。
需要重点关注:
平滑升级:避免升级导致大量连接同时断开。 |