实时数据推送系统架构实践:从轮询到秒级推送的升级之路
接手rea这个项目的时候其实没什么高大上的背景。业务方就丢过来一句话“看板上的数据能不能再快一点现在五分钟刷新一次领导总觉得是在看历史。”于是内部立项代号rea全称写的是Real-time Enterprise Application目标很朴素让浏览器里的经营看板从分钟级刷新进化到秒级甚至毫秒级推送。但这东西做起来远没有喊口号轻松。实时应用和传统请求-响应模式完全是两种思维方式服务端怎么推、推什么格式、推多大量、客户端怎么接、断了怎么办、渲染扛不扛得住每一个环节都能炸。整个项目从原型验证到上线稳定前后折腾了三个多月中间推翻了两版方案。把这段过程整理出来是因为rea踩过的坑、总结出的经验在实时数据推送这个领域相当有代表性不管你是做数据可视化、监控告警还是IM、协同编辑底层要解决的问题都是同一套。这篇文章适合那些正准备从轮询往实时推送迁移、或者已经在做实时应用但被性能问题折磨得够呛的开发者。1. 项目定位与整体拆解1.1 先明确rea的边界rea这个代号听起来很像一个独立产品但实际上它只是我们内部的一个基础实时数据通道外加两层薄薄的业务封装。第一版的范围框得很死只做实时经营看板数据源是内部某个销售系统的订单流水展示端是浏览器里的几个大屏页面。不做IM不做协同编辑不做复杂的离线计算全部精力集中在一个问题上——订单一旦落库最快多久能出现在看板上。这个边界很重要。实时应用最大的坑就是范围模糊一开始什么都想实时到最后什么都做不实时。我们把范围砍到只剩一条数据链路数据库 → 采集 → 计算 → 推送 → 渲染。每一环都有明确的延迟预算整条链路的目标是端到端尽量控制在1秒以内。当时很多同事不理解说订单实时看板有什么难的WebSocket一开数据往浏览器一推不就行了实际上难点在于数据量和聚合逻辑。订单流水不是每秒一条两条在业务高峰期能到每秒数千条前端不可能直接接收原始流水必须经过聚合降维才能展示。所以rea从一开始就不是一个简单的消息推送工具而是实时采集 窗口聚合 增量推送的组合体。1.2 为什么非要用实时推送而不是继续用轮询每次做技术选型总有人问现有的轮询不是跑得好好的吗为什么要换这个问题我反反复复解释过很多次其实答案就三条。第一轮询的延迟不稳定。五分钟一次数据的最大新鲜度就是五分钟。哪怕改成每秒轮询一次延迟上限还是在的而且轮询间隔越短无用的空请求越多。第二轮询对服务端的负载是线性增长的。看板从几块加到几十块每个看板每秒轮询一次后端接口QPS直接翻几十倍而大部分响应内容根本没变化。第三轮询解决不了事件的即时性。订单突然暴增、指标突然下跌这类异常事件轮询永远只能在下一个周期被动地拉到做不到主动感知。实时推送刚好反过来——服务端只有在数据变化的时候才发送消息空闲时不产生任何流量。数据产生的一瞬间消息就沿着链路冲到了浏览器延迟天然就是低的。当然实时推送也引入了新的复杂度后面讲的那些坑基本都是实时化带来的。1.3 整体架构的分层设计rea的架构分五层每层只干一件事。接入层负责连接管理承担所有浏览器客户端的WebSocket接入处理连接鉴权、心跳保活、断线重连。计算层是整条链路的核心把采集到的原始订单流按时间窗口聚合产出指标数据。存储层承担两类职责一是把原始明细落到持久化存储里用于事后对账二是把聚合后的最新指标缓存在内存中供新接入的客户端快速拉取初始状态。推送层负责把聚合结果经过序列化、批量化处理后通过连接下发到客户端。展示层就是浏览器里的看板页面负责订阅、渲染和用户交互。这五层前后端职责分得很清楚。前后端唯一的接口就是推送层定义的WebSocket消息协议只要协议不变两边各自演进。2. 核心技术栈与选型逻辑2.1 通信层WebSocket、Socket.IO还是SSE落地第一步就是选通信协议。我们当时在WebSocket、Socket.IO、SSE三者之间纠结了很久也做了一个简单的对比。WebSocket是全双工通信服务端可以主动推送浏览器原生支持不依赖第三方库这是它最大的优势。但原生WebSocket的重连、心跳、粘包处理都要自己写而且写出来还未必健壮。SSE走的是HTTP流用EventSource接口自动重连是内置的但它是半双工的只能服务端往客户端推客户端往服务端发消息得另走HTTP请求而且对浏览器的连接数有限制。Socket.IO则是一个封装好的实时库底层优先走WebSocket不支持的时候自动回退到HTTP长轮询自带心跳、重连、房间、广播这些机制。最终我们选了Socket.IO。这是基于常见实践补充的选型思路核心考量就是容错。生产环境里有大量的内网用户、老版本浏览器、奇怪的代理服务器原生WebSocket在这种环境里经常莫名其妙断开连不上排查起来还很痛苦。Socket.IO的回退机制虽然偶尔也有坑但至少不会让用户完全连不上。代价是引入了一个相对重的依赖还需要多维护一层协议转换。如果是在全新的、可控的网络环境里做直接上原生WebSocket完全够用。不用Socket.IO还能省掉很多协议开销和调试成本。这个取舍没有标准答案关键是看你的用户画像。2.2 计算层内存时间窗口聚合聚合逻辑是rea最容易写崩的地方。实时看板展示的指标无非就是订单量、销售额、退款率、客单价这些但原始数据是一条一条分散的订单必须按时间窗口把几条、几十条、几百条聚合成一个新指标。窗口选多大边界怎么切迟到数据怎么处理这里面全是细节。我们用的是滑动窗口精度单位是秒。每个窗口保存最近60秒的聚合状态内部按秒分桶每秒钟一个桶桶里放着订单量、销售额、退款金额、订单总数这些累加器。新的订单进来以后根据订单时间戳散列到对应的秒桶把累加器值加上去。窗口自然滑动过期桶直接丢弃。这个方案的好处是实现简单、内存可控、延迟极低。60个桶每个桶数据量很小整体占用可以忽略不计。坏处是它只适合做聚合指标做不了精确去重、UV这种复杂计算。我们当时的业务正好不需要这些所以这个方案能稳稳接住。关于迟到数据我们做了这样的处理一条订单到达计算层时如果它的订单时间已经落在过期桶的范围内就按迟到数据丢弃但在丢弃前会写一条日志记录时间偏差的分布。业务方接受了这个方案毕竟看板追求的是趋势和实时性不需要对单笔订单锱铢必较。如果业务强一致就要引入更重的流处理框架做事件时间和水位线管理复杂度会上去一个量级。2.3 展示层前端不能每毫秒都重渲染后端把消息以极快的速度推过来前端如果照单全收、每一条消息都触发热更新渲染浏览器很快就会卡死。所以展示层的核心策略是——后端推得快前端要自己控制节奏。我们前端的渲染方案经历了三个阶段。第一个阶段是收到一条消息就setState一次React组件整个重渲染结果就是页面每秒掉帧超过20次大屏看着像幻灯片。第二个阶段是引入requestAnimationFrame做节流保证渲染频率不超过每秒30帧能看了但消息突然爆发的时候还是会有渲染波动。第三个阶段才是正解——增量更新加虚拟列表。所谓的增量更新是指把聚合结果数据结构化地用差异描述下发比如第5秒的订单量加12前端拿着这个差异直接更新内存对象然后做局部渲染。这样数据量小、序列化快、渲染也只影响变化的那部分DOM。虚拟列表则用在实时明细流上因为前端不可能无限渲染DOM节点只渲染视口内的那部分行。这里有个容易忽略的点虚拟列表和实时增量刷新结合起来滚动位置很容易跳动。因为你正在看第100行数据往上插了一屏列表的offset就变了。我们的处理方案是把列表锁在底部自动滚动模式用户不主动滚动时新数据来了就平滑滚到最底一旦用户手动拉动滚动条就暂停自动跟随。这个交互细节非常小但直接决定了看板好不好用。3. 实操过程与核心环节实现3.1 服务端消息协议设计协议设计是rea最早期定下来、全程没怎么改过的部分。协议没做好后面压测、联调、排查问题都要付出成倍成本。我们的消息帧协议格式大致如下{ v: 1, type: metrics:update, seq: 892317, ts: 1718841600123, payload: { metricId: order_amount, timestamp: 1718841600, window: 60, value: 128536.45, delta: 2890.12 } }字段看上去多其实每个都有用途。v是协议版本方便将来做不兼容升级。type标识消息类型比如订阅确认、心跳、数据更新、服务端错误。seq是全局自增序列号用来做消息顺序校验和去重客户端发现seq有跳变说明中间丢了消息主动请求补发。ts是服务端发送消息的时间戳用于统计链路延迟。payload承载具体业务数据服务端下发的最新查询结果是什么增量变化是什么一清二楚。协议定完以后我们加了一条硬性规定客户端不解析任何未知字段都必须按结构校验。字段类型错了就丢弃消息记录错误日志而不是尝试容错。实时系统最怕出现脏数据宁可丢消息也不能让错误数据渲染到看板上给业务方造成误导。3.2 前端订阅管理与重连机制前端SDK的订阅管理直接决定rea的稳定性。我们的客户端逻辑很清晰整个订阅生命周期分为五步建立连接、握手鉴权、发送订阅、维持心跳、断线重建。建立连接是第一步Socket.IO在连接成功后客户端需要等待服务端返回连接确认消息才算已连接。握手鉴权放在连接确认之前客户端发送业务token服务端校验通过后才分配对应的订阅通道。订阅和鉴权分开好处是节省服务端资源一个连接可以订阅多个数据主题先连上通道再按需订阅。重连机制是踩坑最多的环节。第一版我们用的是Socket.IO的自带重连重连间隔写死3秒结果高峰期一断线几百个客户端同时重连服务端直接被冲垮。后来改成带抖动的指数退避算法重连间隔从1秒起步每次失败翻倍最大不超过60秒同时加上随机抖动。客户端还加了一个会话恢复的逻辑重连成功后自动重新订阅上次的所有主题。核心代码大概是这样的class RealtimeClient { constructor(url, token) { this.url url; this.token token; this.baseDelay 1000; this.maxDelay 60000; this.currentDelay 1000; this.subscriptions new Set(); this.connected false; } connect() { this.socket io(this.url, { transports: [websocket], reconnection: false // 禁止库内置重连走自己的策略 }); this.socket.on(connect, () { this.currentDelay this.baseDelay; this.authenticate(); }); this.socket.on(auth:success, () { this.connected true; this.resubscribeAll(); }); this.socket.on(disconnect, () { this.connected false; this.scheduleReconnect(); }); } scheduleReconnect() { const delay this.baseDelay * Math.pow(2, Math.min(this.reconnectAttempts, 6)) Math.random() * 300; const cappedDelay Math.min(delay, this.maxDelay); setTimeout(() { this.reconnectAttempts; this.connect(); }, cappedDelay); } subscribe(topic) { this.subscriptions.add(topic); if (this.connected) { this.socket.emit(subscribe, { topic }); } } resubscribeAll() { for (const topic of this.subscriptions) { this.socket.emit(subscribe, { topic }); } } }这套代码看着不复杂但有几个细节很关键。一是reconnection设为false因为内置重连策略和我们的指数退避策略会打架必须只保留一套。二是重连成功后要重新订阅而且是主动emit订阅而不是等数据来确保服务端知道你是真的重新上线了。三是断连期间要启动一个本地缓冲队列收到用户交互产生的数据变更请求先缓存重连成功后集中补发避免断连期间用户的切换操作丢失。3.3 性能压测与参数调优rea上线前的压测是我们整个项目里最有价值的一段经历。我们搭了一个模拟订单源以每秒5000条的速度生成订单数据经过聚合层后向1000个模拟客户端推送消息。压测跑了一小时暴露出两个性能瓶颈。第一个瓶颈在计算层。聚合逻辑用的是单线程处理高峰期CPU占用飙到85%导致聚合结果产生延迟。我们去掉了一个隐藏的性能杀手——在每个订单的聚合函数里我们居然做了一次字符串拼接用来生成调试日志虽然日志级别设置了关闭但字符串拼接的代码还在执行。把这个去掉以后CPU占用直接降了一半。这看起来是个很低级的错误但在日志框架普遍做了懒处理的今天很多人写着写着就忘了手动拼字符串也会产生无用开销。第二个瓶颈在推送层。1000个客户端每个客户端每秒收到好几条消息服务端序列化成了大瓶颈。我们用了批量合并的思路把同一个时间窗口内要推送给同一个客户端的所有消息合并成一条大的消息而不是每条指标单独序列化一次。字段越多的消息批量合并的收益越大。压测数据是吞吐量提升约三到四倍消息格式的合并逻辑也不复杂只是把payload里的单条数据换成数组。压测完我们还定了一个延迟目标P99链路延迟必须在200毫秒以内。这里的链路延迟是从订单进入计算层开始到客户端浏览器收到消息为止。少了端到端指标压测就失去了意义。最终我们压测结果是P99在180毫秒左右勉强达标。4. 常见问题与排查技巧实录4.1 消息积压导致浏览器渲染卡死上线后遇到最诡异的问题是某个大屏看板刚开始运行时一切正常运行半小时后页面越来越卡最终整个浏览器标签页崩溃。刚开始我们怀疑是内存泄漏查了一圈发现不是问题是消息积压。原因很隐蔽。Socket.IO的客户端自带消息缓冲队列如果前端处理消息的速度跟不上服务端推送速度队列里的消息就会积压。我们用了requestAnimationFrame节流之后渲染频率被限制在每秒30帧但服务端推送频率在某些指标上有每秒60帧甚至更高的突发。队列积压越来越多内存占用越来越高页面就崩了。排查办法是用Chrome的Performance面板抓取渲染线程的火焰图你会发现大量时间都消耗在消息解析上而不是真正的DOM渲染。解决方案有两个一是在客户端加入丢弃策略对于中间态的数据只保留最新值丢弃积压的旧消息比如实时看板的指标更新本来就是覆盖式的来不及渲染的最新值才是关键二是让服务端感知客户端的处理能力Socket.IO支持暂停和恢复数据流客户端发现队列超过阈值就emit一个pause服务端停止推送恢复渲染后再emit一个resume。参考常见实践很多生产级部署用的是两者结合。4.2 断线重连风暴某一周连续出现两次服务端报警连接数从几百瞬间冲到上千数据库连接池被打满。查下来是断线重连风暴导致的。触发场景通常是内网做了一次整体网络切换所有客户端的连接同时断开。第一版的重连策略是固定3秒重试一次几百个客户端在同一个时间点重连全部撞在一起服务端压根来不及处理。这是教科书级别的惊群效应。修复的手段就是前面讲的指数退避加抖动。关键参数是初始延迟1秒、倍增系数2、最大延迟60秒、抖动范围0到300毫秒。抖动的作用是让重连时间点尽可能分散避免整齐划一的脉冲式重连。服务端侧也要加一道防御收到相同身份标识的新连接时主动踢掉旧连接而不是让旧连接超时等死。这样断线重连是新的连接替代旧的连接总数不会因为重连而线性增长。4.3 消息延迟的误判和时钟偏差项目验收时业务方质疑我们数据有延迟因为他们拿手机上的时间和看板上的时间对发现差了三四秒。但服务端日志显示端到端延迟只有100多毫秒两边对不上这就引出了时钟偏差问题。这里的原因是我们客户端页面有一个数据最后更新时间的显示逻辑最初用的是客户端设备的本地时间。某几个办公电脑的系统时钟本身就被NTP同步得不太好和服务器时间差了好几秒导致客户端显示的时间和服务端实际推送时间对不上给人造成了延迟很大的错觉。修复很简单统一以服务端时间为准。客户端在连接握手阶段就拿到服务端时间戳把本地设备时间和服务端时间的偏差算出来显示更新时间的时候用服务端时间戳加上链路耗时估算值。这条经验不止适用于rea凡是做实时类应用涉及数据新鲜度展示的务必要把客户端看到的时间和服务端记录的时间统一基准否则业务方分分钟来投诉技术问题。4.4 一个隐蔽的本地缓存更新竞态最后再记录一个隐蔽bug。我们的服务端在缓存里存了每个看板的最新指标快照新客户端接入时主动拉取快照作为初始状态。这个逻辑本身没什么问题但快照和后续推送的增量消息之间有一个竞态窗口。发生顺序是这样的客户端发起拉取快照请求服务端返回快照之后、订阅建立之前恰好有一条新的增量消息已经通过另一个通道推送出来了但此时客户端还未完成订阅这条增量消息就丢了。客户端拿着旧快照继续等增量结果永远等不到那条丢了的数据看板上的数字就比真实值少了一点。修复方案也不复杂客户端拉取快照的时候带上一个快照版本号服务端在生成快照的同时记录当前的消息序列号客户端拿到快照后把该序列号之后的所有消息都重新拉一遍再做合并。这样快照加增量补齐保证客户端看到的最终状态是一致的。这个坑在实时系统里非常经典如果不处理削峰填谷的时候必有脏数据出现。5. 一些可以继续深挖的方向rea第一版上线稳定之后我们其实只完成了骨架。从做产品的角度看还有很多值得继续做的点你如果也在做类似项目可以提前考虑。第一个方向是多租户隔离。目前rea是内部工具不区分租户所有业务方共享同一个数据通道。一旦要对外开放或者内部多个团队共用就必须在消息协议里加入租户标识并且把服务端的通道隔离做起来不然一个团队的数据波动会影响所有人。第二个方向是权限到指标级别。现在整个看板页面级传输数据页面做出来了就都能看到里面的所有指标。后续可以做到指标维度的权限控制每个客户端订阅时传递身份信息服务端校验该身份是否有权订阅这个指标。这不难但需要在早期协议设计时就埋好身份字段晚了改动成本很高。第三个方向是数据血统和审计。实时看板的数据被拿来作为经营决策依据出错了谁来负责所以链路里每一条消息最好都带上数据源头标记方便追溯这条数据是从哪条订单、哪个计算桶来的。我们上线后因为数据对不上花了大量时间翻链路日志如果有数据血统这个时间可以节省一大半。还有一点算是经验之谈实时系统上线后一定要有完善的监控告警不仅监控服务端的CPU、内存、连接数还要监控链路的端到端延迟、消息丢弃率、客户端重连次数。我们用了一个很土的办法在服务端记录每条消息的发送耗时在客户端记录每条消息的接收耗时段分布汇总起来画曲线。这个监控体系比什么先进框架都重要因为实时系统一出问题就是动态的、瞬时的没有监控根本无从下手。你在做自己的实时应用时建议第一个版本就把监控打点埋进去。后续你会发现这套埋点帮你省下的排查时间远比当初写埋点代码花掉的时间多得多。