最近在复盘我们团队从零搭建的社交App后端,老实说,市面上聊产品体验、聊UI设计的文章很多,但真正落到后端架构和消息推送这个层面,能讲清楚的干货反而很少。很多刚入行的朋友一提社交App,第一反应是"不就是用户注册加发消息吗",等真到自己上手,才发现光是一个消息推送就能折腾掉大半条命。这篇文章我想把实际落地过的一套方案掰开揉碎,从服务拆分、消息通道选型,到推送链路实现、线上问题排查,完整过一遍。无论你是后端开发、架构师,还是刚接触实时通信的学生,看完应该能对社交App的核心技术栈有一个系统性的认识。
先交代一下背景:这个项目是一款面向垂直人群的社交产品,上线初期注册用户并不算多,但实时互动频率很高,私聊、群聊、动态评论、系统通知都要用到消息推送。整个后端从单体起步,一步步演进到微服务,中间踩过不少坑,也沉淀了一些值得复制的方法。下面我就按实际推进的顺序来讲,不会堆概念,只讲那些真的在线上跑通过、也为业务扛住过压力的内容。
1. 从架构设计说起:社交App后端到底在解决什么问题
1.1 社交场景的三个核心命题
聊后端架构之前,得先想清楚一件事:社交类产品对后端的要求,和普通业务系统有本质区别。普通系统核心是"增删改查",社交系统的核心则是三件事:连接、实时、一致性。
连接,指的是海量客户端与服务器之间需要保持稳定长连接,用户在线、收发消息、状态同步都得靠这个连接。实时,指的是消息延迟要足够低,尤其私聊、群聊场景,用户发出一条消息,对方如果几百毫秒内收不到,体感就非常差。一致性,指的是消息的顺序不能乱、不能丢、不能重复,像聊天记录这种数据,一旦出现乱序或者丢失,用户很容易就会发现。
这三件事互相牵扯,共同决定了后端的技术选型。只做HTTP接口的普通写法,是扛不住这种场景的。
1.2 为什么我没有一上来就上微服务
现在聊架构,很多人默认就是微服务,好像不用微服务就不够先进。但我实际的经验是:小型社交产品起步阶段,单体架构完全够用,甚至更合适。
我们最开始就一个Spring Boot应用,MySQL存核心业务数据,Redis做缓存和在线状态,自己要写的东西不多。等用户量和消息量上来之后,再逐步拆出独立的推送服务、消息服务、用户服务。这个演进过程的收益非常大,因为每一个拆分动作都是基于真实痛点,而不是为了架构而架构。
拆分的节点我建议盯三个指标:
- 某个模块的代码量开始明显膨胀,团队协作频繁冲突;
- 某个独立场景的并发量已经可以和主业务流程分开治理;
- 某个模块的发布频率明显高于其他模块,需要独立扩展。
满足其中一个,就可以考虑拆了。我们是先拆的推送服务,因为消息推送最大的特点是IO密集、连接数多、和业务接口的资源消耗明显不同,放在一起很容易互相拖累。
1.3 整体服务划分和技术选型依据
演进到中期,后端大致分成了这样几块:
| 服务模块 | 主要职责 | 核心技术选型 |
|---|---|---|
| 接入网关 | 连接鉴权、路由转发、限流 | Nginx + Spring Cloud Gateway |
| 用户服务 | 注册登录、关系链、用户资料 | Spring Boot + MySQL |
| 消息服务 | 私聊/群聊消息收发、历史记录 | Spring Boot + MySQL + MongoDB |
| 推送服务 | 维护长连接、消息下发、离线补偿 | Netty + Redis + Kafka |
| 通知服务 | 系统通知、动态提醒 | Spring Boot + Kafka + 定时任务 |
网关层解决的是统一接入和鉴权,用户服务解决身份和关系,消息服务解决业务逻辑,推送服务解决实时触达,通知服务处理非实时场景。存储上没有用一种数据库打天下:MySQL管事务性强的关系数据,MongoDB管消息记录这种高写入、结构化要求不高的数据,Redis管在线状态和离线消息缓冲,Kafka管流量削峰和解耦。
这套组合不算新,但很稳,每一层都有明确用途,也给后面做消息推送铺好了路。
2. 消息推送方案选型:从轮询到长连接,到底该怎么选
2.1 先搞清楚SSE消息推送是什么意思
很多新手一上来就问:"消息推送到底该用WebSocket还是轮询?"其实中间还夹着一个经常被忽略的方案——SSE,全称Server-Sent Events,服务器发送事件。
SSE是一种基于HTTP协议的单向推送技术,客户端发起一次HTTP请求,服务端保持这个连接不关闭,有数据更新时再往这条连接里推数据。它和WebSocket最大的区别在于方向:SSE是服务端单向下发,WebSocket是双向通信。我们的即时聊天场景,用户不仅要收消息,还要发消息,所以聊天主链路用了WebSocket;但像系统通知、动态点赞这类只需要服务器单向推送的场景,SSE完全够用,而且实现成本低得多。
SSE还有一个很实用的特性:支持自动重连。客户端断网后,浏览器会自动重新发起连接,并且能带上上次接收到的消息ID,服务端可以据此续推未送达的数据。这一点在WebSocket里是需要自己处理的。
选型时我的建议是:不需要客户端上行数据的推送场景,优先考虑SSE;需要双向实时交互的,才上WebSocket。
2.2 微信这类头部应用的做法给了我什么启发
做推送方案的时候,我也研究了不少头部产品的公开资料。微信这类应用在移动端的消息推送策略,核心就是两条腿走路:App在前台时,通过自建的长连接通道收消息;App在后台或被杀掉时,靠系统级推送通道唤醒。
这个策略有两个关键点值得借鉴。
第一,自建长连接不可能全平台通吃。iOS上App被挂起后,自建TCP/WebSocket连接很快会被系统切断,这时候只有走苹果的APNs才能触达用户。Android这边由于各家厂商限制,最终还得接入厂商推送通道。所以纯自研通道在移动端是走不通的,必须和系统推送做好配合。
第二,长连接的重点在于心跳和保活。头部应用在心跳策略上做了很多优化,比如根据网络状态动态调整心跳间隔,避免高频心跳浪费电量和流量,也避免低频心跳导致连接被运营商回收。我们后来也参考了这个思路,实现了自适应的心跳间隔,实测下来连接稳定性和省电表现都有明显提升。
顺便说一下Metax这类推送中间件。我们团队内部也讨论过是否直接用现成的推送服务,后来考虑到社交业务对自定义消息格式和推送策略的要求比较高,还是选择了自研推送网关。但Metax这种成熟的推送组件,在不需要深度定制的场景下确实能省不少事,尤其是在私有化部署或内网消息通知场景,开箱即用,不必重复造轮子。
2.3 四种实时推送方案对比
把主流的实时推送方案放一起看,会更清楚它们的区别和适用场景:
| 方案 | 通信方向 | 实时性 | 实现成本 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 客户端单向请求 | 差,秒级最差 | 极低 | 低频通知、兼容老系统 |
| 长轮询 | 客户端单向请求 | 中,秒级 | 较低 | 网页端兜底方案 |
| SSE | 服务端单向推送 | 高,毫秒级 | 低 | 系统通知、动态流、单向下发 |
| WebSocket | 双向实时通信 | 高,毫秒级 | 中 | 私聊、群聊、实时互动 |
注意,我在实际项目里并不是只选一个,而是组合着用。移动端主链路WebSocket,网页端的非聊天通知走SSE,极端网络环境下再退化成长轮询兜底。不同端、不同场景可以用不同通道,架构上留出抽象层就好。
2.4 第三方推送和自建通道怎么配合
这块是很多团队容易踩坑的地方。有人觉得自建通道太麻烦,干脆全靠第三方推送,结果发现消息到达率不稳定,特别是在国内Android生态下,厂商限制越来越多,第三方推送的到达率和实时性很难保证。也有人头铁全自研,结果移动端后台保活问题一拖再拖,用户经常收不到消息,体验直线下降。
我的经验是:移动端必须分层。
- 第一层:在线通道,也就是App在前台或活跃状态下,走自己的WebSocket长连接,保证实时性;
- 第二层:离线通道,App在后台或被系统挂起时,通过APNs、FCM或国内厂商推送通道下发一条轻量通知,用户点击后再建立长连接拉取详情;
- 第三层:兜底通道,网络切换或推送通道异常时,通过定时轮询或下次启动时的增量拉取,保证消息最终不丢。
这套三层结构,是我们在多次线上事故之后总结出来的。可能听起来不炫酷,但实用、抗造,用户对消息可靠性的感知远远好于单纯的华丽架构。
3. 核心链路实现:从一条私信到对方手机上的完整旅程
3.1 消息发送主流程拆解
一条消息从发送方到接收方,后端要经手六个环节:接入、鉴权、存储、推送、回执、离线补偿。我用一条私信的发送流程来说明。
用户A发送消息给用户B:
- A的客户端将消息通过WebSocket发送到推送网关;
- 网关先做基础校验,确认A的登录态有效;
- 校验通过后,消息进入消息服务,生成全局唯一消息ID;
- 消息写入存储,同时发送一份副本到Kafka;
- 消息服务查询B的在线状态,如果在线,则通过推送网关转发给B;
- 如果B不在线,消息进入离线缓冲,等B上线后再拉取;
- A的客户端收到服务端确认回执,界面上显示发送成功。
这里面的关键是第3步,全局唯一消息ID。它是后续做幂等、去重、补拉的基础。消息ID我建议用"雪花算法"生成,既保证全局唯一,又带有时间信息,方便排序。
3.2 在线状态管理和IM连接网关搭建
在线状态是推送的前提,状态不准,推送就是瞎猜。在线状态我用Redis维护,key是userId,value是连接节点ID和最近心跳时间,TTL设置为心跳间隔的3倍左右,防止网络抖动导致状态被误清理。
WebSocket接入层采用Netty实现,可以支持高并发长连接。每个用户连接建立后,推送服务会把它注册到本地连接管理器,同时把Online事件写入Redis。用户在分布式环境下可能连到不同的Netty节点,所以跨节点转发还需要一层路由:根据Redis里维护的连接节点ID,把消息转发到对应节点。
伪代码大致长这样:
// 连接注册与路由 public class ConnectionManager { // 本地节点维护的连接 private Map<String, Channel> localChannels = new ConcurrentHashMap<>(); // 判断目标是否在本节点 public boolean isLocal(String userId) { String nodeId = redis.get("online:" + userId); return currentNodeId.equals(nodeId); } // 跨节点转发 public void routeMessage(String userId, MessagePacket packet) { if (isLocal(userId)) { Channel channel = localChannels.get(userId); if (channel != null && channel.isActive()) { channel.writeAndFlush(packet); } } else { pushServiceClient.forwardToNode(userId, packet); } } }心跳保活这块,我采用了自适应策略:默认间隔60秒,连续三次心跳正常就把间隔拉长到90秒;一旦发现连续两次心跳超时,就立刻缩短到30秒并尝试重连。这样在弱网环境下能快速感知连接异常,在稳定网络下又能节省资源。
3.3 离线消息补偿与幂等去重
再好的长连接,也会有到不了的时候。离线消息补偿是消息推送的保底工程,做不好就等着用户来骂吧。
补偿策略分两步:
第一步是离线缓冲。用户B不在线时,消息写入Redis的离线队列,key是"offline:{userId}",value是Sorted Set,score用消息ID。等B上线,推送网关检测到上线事件,触发一次离线消息补拉。
第二步是增量拉取。每次客户端重连成功后,带上本地最后一条消息ID,服务端返回该ID之后的所有消息。这个机制也能解决连接断开期间的消息漏收问题。我们让历史消息同时存在MongoDB里,查询性能不错,也能撑住消息量和索引压力。
幂等去重主要靠消息ID。客户端每次收到消息,会先检查本地缓存是否已经处理过该ID;服务端在转发和存储时也会对同一消息ID做去重。整个链路里,消息ID从生成到消费全程透传,不做任何修改,这是最基础也最重要的约定。
下面是我们消息存储的简化结构:
{ "msgId": "7217349167883165697", "from": "user_1001", "to": "user_2002", "convId": "conv_1001_2002", "type": "text", "content": "你好,今晚一起吃饭吗", "status": "delivered", "createdAt": 1732958102000 }convId是会话ID,用于拉取两人之间的历史聊天记录。type定义消息类型,后续加图片、语音、视频时只需要扩展这个字段。
3.4 推送网关的高可用与流量削峰
消息推送有个特点:流量在短时间内可能暴涨。比如群聊里某个热点话题突然引爆,一条消息发出去,要推送给几千上万人,瞬时下行流量非常可怕。这时候如果所有推送都同步处理,服务很容易被压垮。
我们的做法是引入Kafka做削峰。消息服务收到一条群聊消息,不直接调推送接口,而是把推送任务写入Kafka,推送服务消费后,再批量并发下发到各个连接。这样即使瞬间有上万条推送任务,也不会直接冲击推送网关,Kafka的积压能力给了系统平稳处理的空间。
同时,推送服务本身按userId做一致性哈希分片部署,每个节点只负责一部分用户连接,节点宕机时其他节点可以自动接管部分连接。这里需要注意:连接是无状态的,但用户和节点之间的绑定关系存在Redis里,节点故障后需要把该节点上的连接全部标记为异常,客户端感知到连接断开后重连,重新注册到新节点。
高可用这件事,没有一劳永逸的完美方案,核心原则就是:任何一个单点都要有降级路径。网关挂了有备份,Redis挂了有缓存,推送通道断了有离线补偿,链路里每一环都要提前想好"如果它挂了怎么办"。
4. 常见问题与排查实录:那些年在推送链路上踩过的坑
4.1 线上问题速查表
推送链路涉及环节多,出问题时排查起来往往很费劲。我把我们遇到过的典型问题整理成了表格,方便按症状定位:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 消息延迟高 | 推送任务在Kafka积压 | 查Kafka消费Lag、消费线程数 |
| 部分用户收不到消息 | 在线状态不准确 | 查Redis在线状态、心跳是否过期 |
| 消息重复收到 | 客户端重连后重复拉取 | 查消息ID去重逻辑、补拉游标 |
| 连接频繁断开 | 心跳间隔不合理/网关超时配置过短 | 查心跳日志、网关空闲超时配置 |
| 群消息发送极端慢 | 群成员逐个推送串行执行 | 查群推送是否走批量并发 |
| App后台收不到消息 | 系统切断自建连接 | 查厂商推送通道是否完成接入 |
这个表看起来简单,但每一条背后都是真实的线上事故。排查时建议先画一条端到端的链路图,把客户端、网关、Redis、Kafka、存储、第三方通道全部画出来,再逐步定位问题落在哪一段,比凭感觉瞎试快得多。
4.2 一次"消息延迟越来越严重"的完整排查过程
曾经有个线上事故,用户反馈群聊消息延迟越来越严重,从最初的几百毫秒涨到几十秒。当时第一反应是Kafka消费能力不足,但看监控发现消费Lag并不高,网关CPU和内存也正常,这就很诡异。
后来把链路逐段测了一遍,才发现问题出在消息存储上。群聊场景下,一条群消息需要给每个群成员生成一个会话内消息索引,导致单条群消息要写很多行记录。群人数多的时候,写库事务时间被拖长,消息服务处理速度下降,Kafka消费线程在等待消息服务返回,消费速率被拖垮,最终表现为推送延迟飙升。
问题根因找到后,我们做了两个调整:一是把群成员的会话索引写入改成异步批处理,攒一批再写;二是把消息入库和推送下发解耦,推送不再等待入库结果才下发。这样改完,延迟又恢复到了正常水平。
这个坑给我们的教训是:推送链路里的每一环都可能成为瓶颈,排查问题不能只看直接表现为"推送慢"的环节,要顺着链路把上下游的依赖关系都审视一遍。
4.3 连接风暴和推送风暴怎么治理
还有两类问题值得单独说,一个是连接风暴,一个是推送风暴。
连接风暴指的是大量客户端同时断线重连,网关瞬间涌入海量建连请求。常见触发场景是网络恢复后,所有离线用户一起回来,或网关发布重启导致大量连接同时断开重连。治理办法是在客户端加随机延迟重连,避免瞬间集中建连;服务端网关也要做半连接队列调优和限流。
推送风暴则是指某条热点消息推到大量用户后,部分用户产生大量后续互动,比如点赞、评论、转发,这些互动又继续触发新的推送,形成雪球效应,最终把网关和Kafka打爆。治理办法是给推送任务分级:重要消息直接推送,低优消息做聚合和限流,比如把某用户短时间内收到的同类通知合并成一条,或者在系统繁忙时先只推"你有一条新通知"的轻量提醒,等用户进入App再拉详情。
这两个风暴治理好,推送系统的稳定性会上一个大台阶。
4.4 留给新手的几条工程级建议
最后给准备做社交App后端的同学几条实用建议,都是用真金白银换来的经验。
第一,先做通道抽象,不要绑定具体实现。我们推送模块最底层定义了一个MessageSender接口,下面挂WebSocketSender、SseSender、FcmSender等实现,上层业务只管调用,不用关心消息从哪条通道出去的。这样后续换通道、加通道都很轻松。
第二,日志埋点一定要细。推送链路每个环节都要打点,消息从进入网关到下发客户端,每一步都要有日志或指标记录。没有完整链路日志,出问题的时候你会像无头苍蝇一样乱撞。
第三,监控要分维度看。不要只看平均延迟,要看p95、p99延迟;不要只看连接总数,要看连接断开率和重连率。这两个维度才能真实反映用户体验。
第四,上线前一定做混沌演练。人工把Redis停掉、把某个网关节点杀掉、让Kafka积压几百万消息,看看系统能不能自愈。现实世界中故障是不可避免的,我们要练的是出故障后快速恢复的能力。
5. 一点个人的体会
这篇内容写到这里,核心的东西基本都覆盖了。回过头看,社交App后端和消息推送这件事,最大的挑战从来不在于某个单一技术点有多难,而在于这些技术点组合起来之后,如何在真实复杂的网络环境下稳定工作。做技术的通病是喜欢追求"高级"方案,但线上场景教给我的却是"稳定"比"高级"重要得多。
我自己在一次次事故中最大的感受是:架构不是设计出来的,是长出来的。一开始不需要贪大求全,把核心链路跑通,让用户能稳定收发消息,然后根据真实业务压力不断做演进,比一上来就堆一大堆中间件的效果要好得多。如果这篇文章能帮你少走一点弯路,那就是我花几个小时把它整理出来的最大价值了。