社交App后端消息推送架构实战:长连接与离线补偿方案解析 最近在复盘我们团队从零搭建的社交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发送消息给用户BA的客户端将消息通过WebSocket发送到推送网关网关先做基础校验确认A的登录态有效校验通过后消息进入消息服务生成全局唯一消息ID消息写入存储同时发送一份副本到Kafka消息服务查询B的在线状态如果在线则通过推送网关转发给B如果B不在线消息进入离线缓冲等B上线后再拉取A的客户端收到服务端确认回执界面上显示发送成功。这里面的关键是第3步全局唯一消息ID。它是后续做幂等、去重、补拉的基础。消息ID我建议用雪花算法生成既保证全局唯一又带有时间信息方便排序。3.2 在线状态管理和IM连接网关搭建在线状态是推送的前提状态不准推送就是瞎猜。在线状态我用Redis维护key是userIdvalue是连接节点ID和最近心跳时间TTL设置为心跳间隔的3倍左右防止网络抖动导致状态被误清理。WebSocket接入层采用Netty实现可以支持高并发长连接。每个用户连接建立后推送服务会把它注册到本地连接管理器同时把Online事件写入Redis。用户在分布式环境下可能连到不同的Netty节点所以跨节点转发还需要一层路由根据Redis里维护的连接节点ID把消息转发到对应节点。伪代码大致长这样// 连接注册与路由 public class ConnectionManager { // 本地节点维护的连接 private MapString, 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 Setscore用消息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后端和消息推送这件事最大的挑战从来不在于某个单一技术点有多难而在于这些技术点组合起来之后如何在真实复杂的网络环境下稳定工作。做技术的通病是喜欢追求高级方案但线上场景教给我的却是稳定比高级重要得多。我自己在一次次事故中最大的感受是架构不是设计出来的是长出来的。一开始不需要贪大求全把核心链路跑通让用户能稳定收发消息然后根据真实业务压力不断做演进比一上来就堆一大堆中间件的效果要好得多。如果这篇文章能帮你少走一点弯路那就是我花几个小时把它整理出来的最大价值了。