开头不用多说,我这次要聊的是一个我最近在练手项目里反复迭代出来的一个东西,内部代号就叫 rea。rea 不是什么惊天动地的框架,我给它起的全称是 Realtime Event Agent,一个用于实时事件分发与推送的轻量代理模块。初衷很简单:后端业务代码里产生了一条业务事件,我要让多个客户端(浏览器、小程序、App)在几百毫秒内收到通知,同时还要保证不丢消息、不重复消费、断线重连后能补拉增量。折腾下来我发现,直接把 WebSocket 裸接业务逻辑是走不通的,事件模型、路由规则、存储方案都得单独设计,于是就有了 rea。
这篇文章会把 rea 从模型设计到服务端实现、客户端接入、上线排障整个链路串起来,重点讲每个步骤背后我当时是怎么考虑的,哪些地方踩过坑,哪些参数是压测之后才调对的。想自己搭一套轻量级实时通道的同学可以直接照着抄,已经有方案想对比一下细节的也可以看看,说不定能避开一些弯路。
1. rea 的整体设计思路与边界
1.1 最初面对的问题
我最初要解决的场景来自一个某跨平台系统里面的通知模块,需求本身不复杂:用户下单后,运营后台要实时看到新订单卡片弹出;用户支付成功后,App 端要收到支付结果推送;还有一类是内部系统之间的状态同步,比如调度任务完成以后,另一个服务需要立刻感知到。
如果场景就一次两次,直接开个 WebSocket 连接、后端往连接里塞消息也就够了。但真正做起来,麻烦事一堆:第一,WebSocket 连接是有状态的,服务端重启后所有客户端连接全断,重连回来的客户端怎么把遗漏的消息补上;第二,业务消息和系统消息混在一起,客户端根本分不清哪条该弹窗、哪条该静默刷新;第三,多个服务实例同时在线,客户端连的是 A 实例,但事件产生在 B 实例,B 怎么把消息送到 A 的连接上。这些问题如果不提前规划,上线后就是事故。
所以 rea 的目标不是“更快”,而是“更可控地分发”。它要做的事可以拆成四块:统一的接入网关、结构化的消息模型、灵活的路由规则、以及可靠的事件存储。想明白这个边界以后,我再也没动过“直接在业务代码里 new WebSocket”这种念头。
1.2 rea 的核心边界与设计取舍
先说说 rea 刻意不做的事。它不做业务消息内容的处理,不知道你的订单字段长什么样;它也不做客户端 UI 渲染,不关心弹窗还是 toast;它同样不做用户体系,不存用户名密码。rea 的职责只到“把某类事件准确投递给有权限关心的连接”这一步,后面的事情全部交给客户端。
这个边界是从之前一个失败原型里总结出来的。最早我把业务逻辑塞进推送模块,结果每次需求变更都要改推送服务,耦合重到后来根本不敢动。后来改成 rea 这种纯透传加路由的模型才顺过来:业务方只需要声明“我要订阅 topicA”,rea 负责把 topicA 的事件推过去,业务方拿到事件自己解析。
架构上 rea 分为三层:接入层、路由层、存储层。接入层管理连接和心跳;路由层根据订阅关系做事件分发;存储层负责把事件落地,用于离线补发。三层互相独立,好处很明显:接入层要扩容就多挂节点,路由层改成 Redis 发布订阅也行,存储层从内存换 Redis Stream 也无缝。对于中小规模项目来说,这个分层已经能覆盖绝大部分需求了。
2. 事件模型与消息协议设计
2.1 事件结构定义
rea 里所有消息统一走一个 JSON 结构,这件事听起来简单,但把字段定清楚花了不少时间。最终版本长这样:
{ "eventId": "evt_20250101_000001", "topic": "order.paid", "version": 1, "occurredAt": 1735689600000, "producer": "order-service", "payload": { "orderId": "ord_123456", "amount": 99.9 }, "traceId": "trace_abc" }字段不多,但每个都有用。eventId 是全局唯一,用于客户端去重。topic 是路由和订阅的基础。version 是消息结构版本,以后 payload 加字段至少不会解析错。occurredAt 是事件发生时间,不是投递时间,这个对离线补发时排序很重要。producer 用来标记来源服务,排查问题打日志时一秒就能定位。payload 才是业务关心的数据。traceId 则是拿来串联整条链路的,从业务操作到推送日志再到客户端上报,一路透传。
不设计成纯数组而是用对象,是为了以后扩展。比如你要加个优先级字段,直接加"priority": 1就行,老客户端不认识这个字段也不会报错,因为 JSON 解析是宽容的。
2.2 订阅与路由规则
订阅关系我用的是“主题通配符 + 集合匹配”模式,和 MQTT 的设计很像。订阅者可以订阅一个精确 topic,也可以订阅带通配符的 topic,比如order.*能收到所有以order.开头的事件。另外支持按条件订阅,事件里带了一个特殊的routingKey字段时,路由层会先做一次匹配再决定要不要投递。
举个例子,某监控系统里,不同团队只关心自己服务的告警。告警事件 topic 是alert.critical,payload 里有serviceName。如果所有团队都订阅alert.critical,每个客户端都会收到全部告警,造成噪音。rea 的路由规则里加了一个可选条件:
topic: "alert.critical" condition: "payload.serviceName == 'billing'"只在条件满足时投递。这个设计不是万能的,复杂的 JSONPath 支持我留在了后续版本,但简单等于判断已经足够解决大多数按业务域隔离的推送需求。
订阅关系最终落到一张路由表,结构大概是“连接ID -> 订阅集合”。每次事件进来先查这张表,找出所有匹配的连接,再逐个推送。路由表的更新操作是加锁保护的,防止服务端在遍历同时有新客户端注册,导致消息漏推或者重复推。
2.3 消息序列化与兼容性
序列化我直接用了 JSON,没有引入 Protocol Buffers。原因很简单:JSON 可读性好,客户端不管什么语言都有原生解析,调试的时候一眼能看出问题。性能方面,如果推送频率到每秒几万条,JSON 确实不如二进制省带宽,但我的场景远没到那个量级。真到那一步,rea 的事件结构可以再套一层 MessagePack,字段含义不变,解析逻辑也不用大改。
兼容性主要靠 version 字段。消费端 SDK 拿到一条事件后,会检查 version,如果版本高于自己支持的版本,会先把 payload 里的已知字段解析出来,未知字段忽略。这里必须提醒一句:payload 里的字段尽量不要复用,新版本加需求就新增字段,别去改旧字段的语义。我吃过一次亏,把某个业务字段从时间戳改成了格式化字符串,结果老客户端解析出来直接显示 NaN。
3. 服务端实现要点
3.1 连接管理与心跳机制
服务端我用的 Node.js,WebSocket 库选的是业界很成熟的 ws 库,没有自己造轮子。连接管理这块核心是两件事:维护连接映射,检测死连接。
连接映射我用一个自增连接 ID 对应 WebSocket 实例,同时在实例上挂了一个metadata对象,里面存订阅列表、最后心跳时间、客户端标记等。为什么不用客户端传上来的用户 ID 做 key?因为同一个用户可能同时在 PC 和手机上登录,一个用户 ID 对应多条连接,用连接 ID 做 key 才能支持多端。
心跳机制是最容易出问题的地方。Nginx 默认 60 秒关空闲连接,所以 rea 的心跳间隔我定的是 25 秒:服务端每 25 秒发一次{"type":"ping"},客户端收到后回{"type":"pong"}。如果连续两次没有收到 pong,也就是 50 秒没有响应,就判定连接已死,直接关闭并清理资源。这个间隔不是拍脑袋定的,它是压测之后才确定下来的。心跳太频繁占带宽,太少又容易积累死连接,25 秒在绝大多数场景下都能覆盖到。
3.2 消息投递与重试策略
投递流程我设计成:事件进入 rea 后,先写存储,然后查路由表,逐个连接推送。这里的关键是“先写后推”。哪怕推送失败,事件已经在存储里了,后续可以补推,不会丢。顺序不能反过来,否则服务端刚推送完就宕机,事件就丢了,客户端永远补不回来。
推送失败分两种情况。一种是连接还在但发送失败,比如客户端半开连接,这种直接进入重试队列;另一种是连接已经不存在了,那就直接把事件标记为待补发,等客户端重连后拉取。
重试策略不是无限重试,我用了“最多重试三次,重试间隔 500ms / 2s / 5s”这种退避方式。超过三次还没成功就把事件丢进死信表,人工排查。为什么不做无限重试?因为如果是客户端本身逻辑卡死,重试再多次也没用,反而把内存耗尽。死信表里的事件至少能用来做告警,不会无声无息。
3.3 存储选型:先内存表后 Redis Stream
最开始我图省事,直接用内存 Map 存待补发事件。客户端量少的时候没问题,但某次压测发现连接一多,内存占用涨得吓人。后来我把存储改成了 Redis Stream,而且不只是待补发事件,所有需要投递的事件都先写 Redis Stream。
Redis Stream 的好处有几点:自带消费组,可以实现多条服务端节点的横向扩展;消息可以持久化,不像内存表那样服务重启就丢;支持按 ID 范围拉取,非常适合客户端断线重连后补数据。rea 的做法是生产事件写入 Stream,同时订阅方从 Stream 里消费并推送给在线连接,离线消息则通过客户端请求拉取。
这里有个细节:消息 ID 直接用 Redis 生成的timestamp-seq形式,这样天然带时间顺序。客户端补发时只要带上最后收到的 eventId,rea 就能从 Stream 里把之后的消息捞出来。Redis Stream 的XRANGE也支持按 ID 范围查询,实现起来几乎没有额外工作量。
4. 客户端接入与 SDK 使用
4.1 连接生命周期与自动重连
客户端 SDK 的设计目标是“业务方尽量无感”。业务方只需要给一个推送地址和登录令牌,SDK 负责连接、鉴权、心跳、断线重连、补拉增量。
连接流程是:先向 rea 的鉴权接口换取一个短期 token,然后带着 token 建立 WebSocket 连接。rea 服务端在连接建立时会校验 token,通过后返回{"type":"connected","replayOffset":123}。这个replayOffset是上次连接断开时记录的消息位置,客户端拿到它以后就能调用补发接口拉遗漏消息。
重连策略我用了“指数退避加随机抖动”:第一次断开等 1 秒,第二次等 2 秒,第三次 4 秒,最大 30 秒后再重置。为什么加随机抖动?某次上线后大量客户端同时断线,如果大家都固定 1 秒重连,服务端瞬间被打爆。加一个 0 到 500ms 的随机偏移,能把并发重连的尖峰削平很多。
4.2 事件监听与去重
客户端 SDK 对外暴露的接口特别简单,核心就两个:
const client = new ReaClient({ url: 'wss://push.example.com/rea', token: 'xxx' }); client.on('event', (event) => { console.log(event.topic, event.payload); }); client.subscribe('order.paid'); client.unsubscribe('order.paid');订阅方法调用后,SDK 会向服务端发一条订阅消息,服务端更新路由表。这里有个注意点:订阅操作必须在连接建立成功之后再发,否则服务端不认识这条连接,订阅会被忽略。SDK 内部把订阅请求先放到队列里,等connected事件触发后再批量发送,就能避免这个竞态。
去重逻辑在 SDK 这一层做掉了。客户端维护一个最近处理过的 eventId 集合,如果重复收到同一条事件,直接丢弃。为什么必须做?因为补发接口和实时推送之间可能存在重叠窗口,比如客户端断线前正好收到一条事件,还没处理完就断线了,重连后补发会把这条事件再拉一次。服务端无从判断客户端到底有没有处理完,只能靠客户端幂等。
4.3 从后端业务代码里发一条事件
给业务方使用的生产者接口,我设计得比消费端更克制,集中在一个简单的 Client 里:
from rea_client import ReaProducer producer = ReaProducer(server="127.0.0.1:6379") producer.publish( topic="order.paid", payload={"orderId": "ord_123456", "amount": 99.9}, producer="order-service", version=1 )生产端不走 WebSocket,而是直接写 Redis Stream,这样最稳。业务服务可能因为网络问题临时连不上 Redis,我就加了一个本地磁盘队列做缓冲,写入失败先落盘,等恢复后再补发。这个缓冲默认上限 10000 条,超过上限直接抛异常让业务方感知,而不是把数据丢掉。生产端做到不丢,消费端重试才能有意义,这是 rea 的核心原则。
5. 上线后的排障与性能调优
5.1 常见问题清单
我把上线前后遇到的高频问题整理成了一张速查表,这些问题处理多了基本不用翻代码就能定位:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 客户端收不到个别事件 | 路由条件配置错误 | 先看 topic 是否匹配,再查订阅是否在连接后才发送 |
| 客户端反复断线重连 | 心跳间隔和代理超时不匹配 | 核对心跳间隔,确认小于网关空闲超时的一半 |
| 推送延迟突然升高 | Redis Stream 消费积压 | 看下消费组 Lag,增加消费者实例 |
| 消息重复收到 | 客户端没做去重或补发窗口重叠 | 确认 SDK 的 eventId 去重逻辑是否生效 |
| 服务端内存涨不停 | 死连接没被清理或内存表膨胀 | 检查心跳超时清理任务,查看死信表数量 |
这里最坑的是第一类问题。曾经有一次告警事件一直推送不到某客户端,查到最后发现业务方订阅的是alert.*,但事件 topic 是alert.critical.disk,通配符只匹配一层。这个问题在管理后台看订阅关系一眼就能发现,所以在路由系统里加了一个“topic 匹配预览”的小工具,输入事件主题就能看到哪些连接会收到,省了很多排查时间。
5.2 压测结果与参数调整
rea 上线前我做了一轮本地压测,压测环境是某实验室提供的两台虚拟机,配置不算高,单机 4 核 8G。我用了自研的小型压测脚本模拟 5000 个 WebSocket 客户端同时在线,后端以每秒 1000 条事件的速度往里灌,最后统计数据是:平均推送延迟 12ms,P99 延迟 45ms,没有出现丢消息。
压测过程中最明显的一个瓶颈是路由表查询。最初我用数组存储所有订阅关系,事件进来以后挨个遍历判断,连接数到 3000 左右 CPU 就开始飙高。后来改成以 topic 为 key 的哈希索引,订阅关系按 topic 分组,事件进来只需要查一次哈希就能拿到目标连接列表,CPU 占用立刻降了 60%。
另外一个参数调整是 Redis Stream 的消费者数量。刚开始只有一个消费者,消费速度跟不上生产速度,Lag 越积越多。后来改成按 topic 分区做多个消费者,每个消费者负责一部分 topic,整体吞吐上去了,Lag 稳定在个位数。这个分区逻辑需要路由层配合,不能无脑起多个消费者消费同一个队列,否则同一个事件会被多个消费者重复取走。
5.3 日志与监控体系
rea 的日志我坚持三个字段必须有:eventId、topic、connectionId。任何一条推送链路,从事件进来到客户端确认收到,都能穿起来。客户端 SDK 收到事件后会上报一个 ack 消息,服务端看到 ack 就把这条投递记录标记为成功。如果一条事件推送出去 30 秒内没收到 ack,就会触发告警。
监控方面我挂了四个指标:在线连接数、事件生产消费速率、待补发消息量、P99 推送延迟。这四个指标足够覆盖大多数故障场景。尤其待补发消息量,这个数如果一直在涨,说明推送链路有严重阻塞,比任何业务指标都直观。
6. 踩过的坑与后续扩展方向
6.1 容易踩的坑
第一个坑就是消息顺序。Redis Stream 本身是按写入顺序排列的,但如果事件先经过本地磁盘队列缓冲,再写入 Redis,那么缓冲重放时可能打乱顺序。我的解决方法是生产端和缓冲重放都走同一个 Redis Stream topic,并且带一个序列号字段,客户端按序列号重新排序。如果你的业务对顺序不敏感,可以不管,但像支付状态这种事件,顺序乱了就会出现“已支付”显示成“已取消”的诡异问题。
第二个坑是 WebSocket 服务端实例横向扩容时的连接迁移。rea 原来把连接路由表存在每台实例内存里,客户端连的是 A 实例,事件却是从 B 实例进来的,B 不知道这个连接的存在。后来我引入了 Redis Pub/Sub,所有实例订阅同一批频道,事件从任意实例进入后广播到所有实例,每台实例只推给本地连接。这个方案虽然增加了一点 Redis 压力,但彻底解决了跨实例投递的问题。
第三个坑是订阅请求的幂等性。客户端断线重连后 SDK 会把之前订阅过的 topic 重新发一遍,服务端如果每次都直接新增订阅记录,同一条连接会出现重复记录。后来在连接的 metadata 里维护了一个订阅集合,subscribe操作变成“如果已经订阅则忽略”,才消掉这个问题。
6.2 rea 后续还能怎么扩展
踏过这些坑以后,rea 基本能满足我现在的实时推送需求,但我觉得它还有几个值得做的方向。一个是支持更复杂的路由条件,比如嵌套 JSON 表达式或者正则匹配,这样能覆盖更多按业务隔离的场景。另一个是把连接层升级成支持 HTTP 长轮询作为降级通道,毕竟不是所有网络环境都允许 WebSocket 长时间在线。
还有一个方向是把协议再抽象一层,支持 protobuf 序列化选项,用于高吞吐场景。JSON 作为默认格式没问题,但某些场景下比如行情推送,每秒几万条消息,JSON 的解析开销和带宽占用确实肉疼。保持现有 topic 和 payload 结构不变,只是序列化方式变成可选,对兼容性影响不大。
最后再说一个我在实际使用中觉得特别有用的经验:rea 这种基础组件一定要把事件模型的版本兼容做好,因为业务方一旦接进来,升级 SDK 的周期不可控。我给 rea 定了一条规矩:任何字段语义都不能在同一个大版本里修改,只能新增,废弃字段用标记标注。坚持这个规矩之后,我几乎没有再被客户端兼容问题半夜叫醒过。
如果你也要从零做一个类似的实时推送系统,先别急着写代码,把事件模型和路由规则想清楚,再考虑选型和技术细节,绝对能省一半返工的功夫。