ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从轮询到事件总线:WebSocket长连接与消息推送的可靠性实践

从轮询到事件总线:WebSocket长连接与消息推送的可靠性实践 做在线工具研发的那几年我最怕的不是接口写不完而是“用户明明在线却一直拿不到新消息”。早年间我先后试过短轮询、长轮询、WebSocket直连方案换了一轮总在“消息丢失”“连接假死”“重复推送”这几个坑里打转。后来我把实时通信从业务模块里彻底抽出来单独做成一套事件总线项目代号就叫“buzz”。这次重构之后线上事故从每周必炸降到了差不多半年没有大问题。这篇文章就把那段时间的完整过程记录下来从为什么放弃轮询、连接生命周期怎么设计到最小闭环开发步骤、生产环境的四个故障和完整排查链路适合正在做IM、协同白板、实时看板或消息通知系统的人参考。如果你之前只停留在“知道WebSocket”的阶段看完应该能直接动手搭出自己的第一版。稍微提醒一句“buzz”在这里是项目代号不是某个商业SaaS我会把它当成一套自己可以落地的事件驱动通信层来讲。1. 高频轮询之外消息系统缺的不只是一个长连接1.1 旧实时方案的三宗罪早期项目里最原始的做法是前端每3秒调一次接口拉最新列表。现在回头看短轮询真正的问题不是浪费带宽而是它让业务形态一直被技术牵着走。你想做实时白板就得把用户的操作包成“增量”等下一次轮询发回来你想做通知未读数就得承担“用户看了消息但已读状态延迟5秒才同步”的糟糕体验。更麻烦的是轮询接口天然无状态服务端没法区分“这个客户端是不是还活着”于是请求频繁地打过来缓存策略、限流策略、监控告警全都得为这种低效方式擦屁股。后来换成了长连接情况好了不少但新的问题又冒出来。每个业务模块各连各的A模块维护一套连接池B模块再维护一套连接管理逻辑散落在各处。某天线上节点重启A模块的连接全部断掉重连B模块却被防火墙的假连接卡住了两边的表现完全不一样。这时候你会意识到实时能力本质上是被业务模块反复复制的一份“脏代码”它不是某个功能而是一套需要统一治理的基础设施。1.2 事件总线把通信从“功能”变成“模型”我决定把连接、心跳、订阅、投递全部收拢到一个独立的通信层里业务侧不再关心对方用的是WebSocket还是普通长轮询只关心两件事我要订阅哪些主题我要往哪个主题发一条事件。这样说有点抽象我举个具体的例子。做一个在线表格协同功能的时候A用户修改了单元格传统做法是前端调一个“保存修改”接口然后B用户每2秒去拉一次数据。事件总线的做法则是这样的链路修改动作被包装成cell.updated事件发布到buzzbuzz根据订阅关系把事件推给所有在线订阅了该表格的用户订阅端收到事件后本地合并状态、渲染界面。这样一来“保存修改”接口变成了“写入事件”它的职责从“把数据存好并等待别人查询”变成了“把变化描述清楚并广播出去”。后端不再需要为每一个实时场景定制一套轮询或推送方案前端也只需要统一接入buzz客户端订阅哪些主题、收到什么结构的事件都按约定来。整个实时能力从“功能点”演变成为“通信模型”。1.3 用业务场景校验设计边界不是所有场景都适合事件总线。我自己做过一组判断用来在需求评审阶段快速区分“该用buzz”和“不该用buzz”场景特点适合事件总线需要谨慎实时性要求毫秒级延迟可接受、秒级可忍受要求严格线性一致性的场景不建议消息量级单主题每秒几十到几千事件单主题每秒百万级流量的场景要重做架构状态管理客户端本地缓存状态事件只做增量更新完全依赖事件流反推全量状态的场景要谨慎设计丢消息容忍度允许少量事件在异常窗口丢失靠快照兜底每一条消息都不能丢的场景必须先有落盘方案判断标准很简单如果丢掉一条事件业务5分钟内还能通过全量拉取或者快照恢复那就可以用事件总线如果丢一条事件就意味着账目不准、状态永远错位那必须先解决可靠存储再谈实时。buzz这套方案适合的是前者它把“实时刷新”这件事从手工轮询里解放出来让团队把精力聚焦在事件本身的设计上。2. “buzz”的连接生命周期心跳、超时和断线重连不该靠运气2.1 连接状态机拆解长连接系统里最典型的问题不是“连不上”而是“以为还连着”。刚开始我把连接状态简单定义成connected和disconnected结果线上经常出现诡异现象服务端日志显示客户端在线客户端却已经断网半小时事件全都憋在发送缓冲区里。后来我把连接状态拆成四个阶段这才把问题看得清楚状态进入条件离开条件CONNECTING客户端发起握手请求握手成功或失败重试ONLINE握手成功完成鉴权注册收到心跳超时或收到服务端主动断开RECONNECT_WAIT检测到网络断开或心跳连续丢失进入下一次连接尝试CLOSED客户端主动关闭或鉴权失败手动重新初始化这里最关键的一条规则是任何一方都不允许直接从一个长连接状态跨越未知状态。客户端收到服务端的CLOSE帧必须先进入CLOSED再由上层逻辑决定是否重启连接服务端发现心跳超时必须先标记为RECONNECT_WAIT把连接从路由表里摘除再去尝试释放TCP资源。如果图省事直接调close()多半会出现半开连接残留。2.2 心跳参数应该怎么定心跳间隔是我见过最容易“拍脑袋”的参数。有人选30秒有人选5分钟选完就再也不管了。但心跳的真正价值不是“证明连接活着”而是“尽早发现死连接”。TCP层虽然有自己的keepalive机制但默认探测周期太长很多网络环境下根本等不到系统主动清理应用层就必须自己承担这个职责。我当时定的参数是这样一组心跳发送间隔15~30秒。太短会放大服务端压力尤其连接数过万之后每3秒一次心跳的压力指数级上升心跳超时判定连续两次未收到心跳响应即45~60秒后判定离线服务端清理周期每10秒扫描一次无心跳连接客户端主动断开判定本地网络不可达的错误必须在3秒内反馈给上层不能等心跳超时。提示心跳间隔要结合服务端连接数和网关超时一起调。如果前面隔着一层负载均衡或CDN它的空闲超时往往比你的心跳间隔短那连接就会被中间层悄悄断掉这是“假死”最常见的来源。调试的时候建议把心跳收发做成日志里的单独字段比如hb_sent、hb_ack这样在告警平台上能直接看出是哪一端的节奏出了问题别把心跳日志混在业务日志里。2.3 断线重连为什么必须用指数退避“断了就马上重连”这个直觉在这次项目里被证明是错的。第一次大规模断网时所有客户端同时重连服务端瞬间被握手请求打满这种“重连风暴”比原始断网还难处理。后来我把重连策略改成指数退避加随机抖动效果非常明显。核心逻辑可以简单描述如下func nextRetryDelay(attempt int) time.Duration { // 基础毫秒数每一次重试翻倍1s, 2s, 4s, 8s ... base : time.Duration(math.Pow(2, float64(attempt))) * time.Second // 加入随机抖动避免多个客户端在同一时间点发起连接 jitter : time.Duration(rand.Intn(500)) * time.Millisecond return base jitter }实际配置里我会再套一层上限最大重试间隔不超过5分钟。重试次数超过5次之后不再无限尝试而是让UI层提示“连接不稳定”由用户主动点击重连。这一步不是为了偷懒是为了防止后台无限重连把移动端的电量和流量耗光。重连上限配合主动提醒用户体感反而更好。2.4 鉴权时机决定安全性下限buzz连接建立之后业务方需要知道当前连接属于哪个用户、能订阅哪些主题。这个鉴权不能放在连接建立之后某个“空闲时刻”再做我一开始真这么干过后来发现只要稍微调整一下协议顺序安全性会差很多。正确做法是让鉴权发生在握手阶段客户端先向业务后端换取一个短时效的ticket再用ticket发起buzz握手buzz服务端收到后校验ticket并在内存中记录该连接对应的用户ID、主题权限、过期时间。一旦ticket过期或权限变更服务端可以主动断开连接让客户端重新获取ticket后再连。这个设计的价值在于连接本身就是携带身份的。后续收到的每一条消息都不需要再做一次数据库查询来验证“这个用户能不能看这个事件”只需要在内存里比对主题权限即可。既省了鉴权开销也避免“连接已建、权限已变”的越权风险。我踩过的坑是在握手时不校验权限而是等客户端订阅主题时才校验结果有用户直接发一条构造的SUBSCRIBE指令订阅了他本不该看的主题。虽然投放端又挡了一层但等于多给攻击者一次试错机会。3. 从零实现最小闭环一个能用的“buzz”大致分四步3.1 先做订阅服务再想消息格式很多人在做实时项目时第一件事就开始设计消息推送格式我觉得顺序反了。buzz的第一步应该是先让一个客户端能成功订阅到一个主题另一个客户端能通过这个主题收到一条内容。订阅服务要支持的最基本能力就三件事连接、订阅、投递。我在服务端设计的握手消息结构大概是这样{ type: hello, client_id: user-1001, device_id: web-chrome-abc, ticket: eyJhbGciOi..., topics: [sheet:discussion:42, notice:user:1001] }客户端连上之后第一帧必须是hello一次性把身份、设备和想要订阅的主题都带上来。服务端返回hello_ack里面带上服务端分配的唯一连接ID以及当前这个连接允许订阅的主题列表。这个设计的好处是握手期间就把订阅关系建立起来后面不需要再用额外的订阅指令减少了一次出错机会。消息格式上我坚持一个原则事件要有类型、有来源、有序号但不携带过多业务字段。最简事件结构长这样{ id: evt_231201_00001, type: cell.updated, seq: 42, ts: 1701123456789, payload: { table_id: tbl_88, row_id: row_12, col_id: col_price, value: 19.9 } }把payload之外的字段视为协议字段业务方只关心type和payload。这样解析逻辑可以保持稳定即使业务模块换了几轮通信层不需要跟着改。3.2 发布端API让业务侧调用不要关心底层协议服务端发布事件时我遇到过一种混乱有的模块直接构造{type:xxx,payload:{...}}塞给连接层有的模块又自己封装一层导致消息格式在中间被改得五花八门。最后我统一在业务侧暴露一个非常薄的接口所有模块共用同一套签名。type EventPublisher interface { Publish(ctx context.Context, topic string, event map[string]interface{}) error PublishBatch(ctx context.Context, topic string, events []map[string]interface{}) error }调用方的代码收敛到极简err : buzz.Publish(ctx, sheet:discussion:42, map[string]interface{}{ type: cell.updated, payload: map[string]interface{}{row_id: rowId, value: value}, })发布端接口保持简洁之后业务方不需要知道当前使用WebSocket、gRPC还是本地内存队列底层切换实现都不会带崩上层业务。另一个细节是必须提供批量发布接口。很多模块在导入Excel时的写入速度是每秒几百行如果逐条发布连接层会被高频小包打满批量接口可以显著降低开销。3.3 用事件序号解决“重复消费”的原始问题实时链路里最容易被低估的一个问题是同一个事件可能被客户端收到不止一次。服务端重试、客户端重连、网关重投都会造成重复。如果客户端不做去重协作场景里就会出现“我明明只改了一次单元格界面却回调了两次”。buzz的做法是给每个连接维护一份“连续事件序号”服务端在投递时给事件追加一个单调递增的seq客户端记录自己当前已经处理到的最大序号。收到事件后先比对序号如果seq等于“当前最大序号1”正常处理并更新最大序号如果seq小于等于当前最大序号判定为重复事件直接丢弃如果seq大于“当前最大序号1”说明中间有事件丢失触发补拉或全量刷新逻辑。这个去重逻辑看起来很简单但很容易被忽略。我的教训是不要用事件自带的id字符串去重因为同一条事件在服务端幂等重试时可能被赋予不同的id而seq是顺序语义能同时覆盖“去重”和“丢消息识别”两个问题。3.4 接入真实模块的回溯白板协作场景为了让步骤更可感我用白板协作场景回放一遍。白板需要把每个用户的画线动作实时同步给其他人。在接入buzz之前我们的实现是每画一笔就调一次保存接口再由对方轮询拉取。接入buzz后整条链路变成这样用户A画了一条线前端把线的坐标、颜色、粗细打包成stroke.added事件发布到主题board:{id}服务端收到后校验A是否有该白板的编辑权限再广播给同主题下的其他连接用户B的buzz客户端收到事件直接把这个笔画渲染到自己的画布上如果B因为断线错过了某几条事件客户端检测到序号不连续就调用一次snapshot接口拉取全量画面再接着消费后续事件。这里有个设计要点我特意保留事件只负责增量更新快照才是最终兜底。事件流的天然缺陷是“可能丢”但白板不可能永久依赖“完全不丢”的网络假设。所以buzz客户端内置了一个策略序号 gap 超过3条就放弃补事件直接全量拉取快照。这个策略成本低、效果好比去逐条追回漏掉的事件省心得多。3.5 最小闭环的验收清单在把buzz推向生产之前我给自己列了一个非常直白的最小验证清单每一条都对应真实的故障场景两个浏览器同时打开同一个页面A端发布事件B端是否能在1秒内收到并渲染关闭B端的网络后再恢复B端是否能在预期时间内完成重连并补齐漏掉的事件服务端直接kill掉一个节点进程所有客户端是否会掉线以及掉线后是否能在30秒内全部完成重连同一连接重复收到同一条事件时客户端是否不会出现重复渲染或状态重复更新无权限用户构造订阅指令时服务端是否在握手阶段就拒绝而不是在投递阶段才拦截。这五条如果全部通过说明一个最小闭环基本可用了。再往后就是各种压测和故障演练但至少在这个阶段团队可以放心地把一两个低频实时模块接上来试水。4. “buzz”上线后的四次故障和排查思路4.1 假连接服务端认为在线客户端已经失联第一次线上故障发生在凌晨现象是部分用户反映收不到通知但服务端监控面板上看到的在线连接数没有明显下降。直觉告诉我这又是“假死”但这一次我冷静下来把排查链路完整走了一遍。第一步拉出异常连接的服务端日志。发现这些连接的hb_ack时间确实在持续更新说明心跳链路是通的。第二步看了网关层连接状态发现客户端出口IP的网络很早就断了。第三步对比客户端日志发现客户端进程其实还活着但它所在网络已经断开很久只是它没有收到任何错误报告。根因清楚了中间代理层把断掉的TCP连接“保留”了服务端发的心跳包到达代理后就被吞掉代理再用自己的心跳机制和服务端保持看起来存活的状态。结果就是底层早就断了应用层还攥着一个假的连接。修复方案有两个层面。服务端把心跳校验从“被动接收hb_ack”改成“必须由服务端发起探测客户端必须在3秒内回包”同时降低心跳探测的容忍度。客户端则增加一个“本地网络状态变化监听”一旦系统网络切换或网卡断开立即主动断开并进入重连流程不等待心跳超时。4.2 慢消费者吃掉整条队的吞吐第二次故障的触发点很诡异一个新上线的模块订阅了某个高频主题但它处理事件的逻辑里有一次数据库查询特别慢。一开始只是这个模块自己消费跟不上后来整个主题的投递延迟越来越大其他正常模块也开始出现堆积。问题在于我在设计主题路由时把多个订阅者放在同一条分发队列里。一个慢消费者就像在一根水管里接了个阻力极大的分支后面的水全被堵住了。修复方式是把buzz的“主题分发”和“单消费者进度”拆开服务端先按主题做广播拷贝每个订阅连接独立消费一个连接慢了只影响它自己不会阻塞同一主题下的其他订阅者。同时给每个消费者设了一个背压上限未确认事件超过500条时buzz客户端暂停从网络层读取新事件只处理本地缓冲处理完再恢复。这比“服务端拼命往慢客户端塞数据”要温和得多。慢消费者的问题最终还是得在业务层优化处理逻辑但背压机制保证了一个慢节点不会拖垮整条主题链路。4.3 重连后顺序错乱一次典型的脏状态现场第三次故障发生在一次网络抖动后的重连。用户反馈白板上刚画的一根线消失了刷新页面后又出现。这类故障属于“事件顺序错乱”的经典场景。排查过程是这样的客户端A在断线前已经消费到seq60断线期间服务端继续往主题里写了seq61~65五条事件。A重连后服务端把上次投递缓存里的数据重新推给它同时新的事件也马上到达客户端。结果客户端在同一个瞬间收到seq59和seq62本地判断62大于“当前最大序号1”触发了一次快照拉取。而拉取快照的时候白板服务端的内存状态恰好还没有把seq61的写入持久化于是快照数据比客户端本地状态还要旧覆盖掉了刚才显示的新内容。这次故障教会我一个原则序号和快照必须来自同一个数据版本。修复方式是在白板服务端维护一个画布版本号所有事件的seq和快照中的base_seq统一递增客户端拉取快照时如果快照的base_seq小于本地已处理的seq就必须等待服务端把快照追平再用快照覆盖本地。从那以后我再也不敢让“客户端本地状态”和“事件序号”各玩各的。4.4 集群重启时丢消息落盘与确认机制缺一不可第四次故障比较严重某次集群滚动重启正好撞上一波投票活动的实时数据大流量结果有大约2000条投票事件丢失。用户侧看到的结果是页面上实时票数突然回退了几十票。根因有两个。第一buzz服务端默认的内存队列在进程退出时没有落盘重启期间仍在队列里的事件全部丢失。第二业务侧发布事件后没有收到ack就认为已经发送成功实际上事件在进入队列后就丢了业务侧毫不知情。我当时的修复分成三层。第一层buzz发布端改成同步等待服务端ackack返回的是“事件已经写入本地持久化缓冲区”而不只是“已进入内存队列”。第二层内存队列改为“先落盘再投递”落盘成功后事件才能被消费者读取。第三层客户端消费成功后主动回ack没有ack的消息在超时后会被重新投递。这三层合起来之后丢消息的概率显著下降。我明白这对一个小型项目来说会增加不少运维成本但涉及用户明确感知的实时数据投票数、库存数、未读数可靠性优先级必须排在性能前面。排查这类问题我有一份通用模板分享给大家先拉服务端日志确认“服务端认为做了什么”再拉客户端日志确认“客户端实际收到了什么”最后拉网络/网关日志确认“有没有在中间环节被丢弃”把三条时间线对齐找出第一个不一致的时间点永远不要跳过根因直接“重启恢复”重启只是掩盖了下一次故障的导火索。5. 从“能跑”到“敢上线”buzz的监控、隔离、灰度与故障预案5.1 可观测性三件套指标、日志、链路追踪buzz上线前我给团队定了三条规矩没有指标不接新模块没有日志不处理线上问题没有链路追踪不准做跨模块排障。可观测性指标我分成了三大类指标类别具体指标告警阈值参考连接健康在线连接数、握手成功率、断线重连次数、心跳丢失率手成功率低于95%告警心跳丢失率超过3%持续5分钟告警事件链路发布QPS、投递延迟P99、消费堆积数、重投次数P99投递延迟超5秒告警堆积数超1万持续1分钟告警客户端质量重连失败率、慢消费者数量、序号gap触发次数序号gap触发次数中某客户端持续超过10次/分钟视为客户端bug日志是这些指标之外的第二道防线。buzz的每条关键路径都会打印一行结构化日志字段固定为conn_id、event_id、topic、phase、cost_ms。遇到问题时按event_id一拉就能看出事件在哪个阶段耗时最多。链路追踪解决的是跨模块问题事件从A服务发布经过buzz到达B服务的客户端三个节点的调用关系必须能串成一条trace链路否则每次查跨模块问题都得靠猜。5.2 订阅鉴权与业务隔离buzz经常会接入多个业务模块最怕出现一个模块的异常流量吞掉另一个模块的正常消息。我在设计时就做了两层隔离。第一层是主题级隔离。每个业务模块使用独立主题前缀比如board:*、chat:*、notice:*服务端对同一前缀下的主题设置独立流量控制。某个游戏模块的活跃用户飙升时只会把game:*的队列打满不会影响chat:*。第二层是消费端隔离。每个模块的客户端连接必须携带业务appKey服务端据此做资源限制和配额。无权限的客户端在握手阶段就会被拒绝连接数超限时拒绝新连接而不是挤压老连接。如果你做的是B端产品还可以考虑更严格的“租户隔离”策略每个租户分配独立的连接池和存储缓冲数据层面完全隔离。buzz的定位是轻量通信层如果租户数量很多并且隔离要求很高建议直接在这一层预留接口不要等到租户冲突爆发后再改。5.3 客户端版本兼容与灰度发布buzz上线后最容易被忽略的是客户端版本兼容问题。旧版前端还在浏览器缓存里运行服务端却已经发布了新协议事件结构一改线上就会立刻出现渲染异常。我的做法是在握手阶段交换一个protocol_version字段。服务端维护一张兼容版本表新版本可以连接旧协议客户端但只会给它下发它理解的旧格式事件。新增字段时统一采用“服务端新、客户端旧自动下行配置兼容”的策略避免一改格式就把老用户全毁了。灰度发布同样是刚需。buzz的灰度我习惯按两层推进第一层按“主题”灰度先让新协议只承载新上线模块的主题老模块继续保持旧协议第二层按用户比例灰度在服务端下发一个connection config只把5%的连接切到新连接模型观察客户端错误率和事件投递延迟稳定后再逐步放量。没有灰度就直接全量替换出事的时候连回滚都需要花掉半天时间。5.4 一次故障演练教给我的事项目交维之后我做了一次模拟故障演练人为创建1000个并发长连接在运行中随机杀掉服务端三分之一的节点同时模拟客户端网络抖动观察buzz的恢复情况。第一次演练结果非常难看。三分之一的连接因为节点下线被强制断开重新连接时又撞上重连风暴预发策略的边界指数退避被极端情况下的大量连接打破服务端短时间内握手请求暴增CPU直接飙到90%。第二次演练前我把重连策略和连接限流做了联动新连接尝试加入一个全局漏斗漏斗满了就直接返回“稍后再试”客户端收到这个信号后自然会退避。演练给我最大的收获是很多故障不是设计时没想到而是没有把它放在“极端并发极端异常”的背景下验证过。心跳、重连、ack这些机制单独看都很合理放在一起就可能在特定触发条件下互相打架。上线前模拟一次节点级故障比上线后手忙脚乱地救火划算得多。这套buzz方案并不是什么复杂的分布式架构它的核心其实只有几件事统一连接、统一事件模型、严格处理顺序和重复消费、留足可观测性。如果你正在做的项目也有实时推送、协作同步或通知系统的需求我建议你不要急着去选一个巨型的消息中间件先按这套思路搭一个30天就能上手的轻量版本。实时系统真正的门槛不在于框架有多强而在于你对连接生命周期、事件序号、重连恢复这些细节的处理有多稳。
返回列表