WebSocket 推送上线之后才发现的三个坑:连接集并发、坏连接与推送降级

发布时间:2026/10/21 2:27:06
WebSocket 推送上线之后才发现的三个坑:连接集并发、坏连接与推送降级 一、先给结论三个坑分别是什么#现象根因我的改法1连接数涨到几十个后日志里开始偶发断连重连集合遍历与增删并发遍历到已被踢掉的连接连接集换CopyOnWriteArraySet发送失败即剔除2浏览器显示已连接但曲线不动半开连接会话对象isOpen()还是 true实际已断推送抛IOException直接剔除连接交给前端重连3服务端偶发 500前端整页报错快照构建异常直接抛出把异常抛给推送线程构建失败返回降级 JSON不让异常逃逸这三个坑有个共同点**它们都不会在单连接、短时间压测里出现。**这也是为什么我写这一篇——上一篇的实测通过其实只验证了功能没验证它能不能连续跑一整天。先看一眼推送链路正常运行时的样子2026-10-14 实拍库里有 10 万条遥测、两台设备在线二、坑一连接集合不是线程安全的2.1 先看推送有多重看板的推送是一个每 2 秒执行一次、遍历所有在线连接的定时任务来源src/main/java/com/iothub/ws/OverviewWebSocketHandler.java/** 每 2 秒向所有在线连接广播一次概览快照 */Scheduled(fixedDelayString${iot.ws.push-interval-ms:2000},initialDelay5000)publicvoidbroadcast(){if(sessions.isEmpty()){return;}Stringpayloadsnapshot();for(WebSocketSessionsession:sessions){try{if(session.isOpen()){synchronized(session){session.sendMessage(newTextMessage(payload));}}else{sessions.remove(session);}}catch(IOExceptione){log.warn(推送失败剔除连接 {}: {},session.getId(),e.getMessage());sessions.remove(session);}}}三个要点先记住后面解释为什么这么写snapshot()在整个循环外只算一次——一次数据库查询、一次序列化广播给所有连接。不是每个连接查一次库发送前synchronized (session)WebSocketSession不是线程安全的两个线程同时往一个 session 写底层可能串包isOpen()为 false 或发送抛异常都从集合里剔除。2.2 为什么集合必须换成 CopyOnWriteArraySetsessions集合面临三种并发访问而且都不加锁线程在干什么推送定时任务遍历所有连接发送WebSocket 处理线程新连接进来时addWebSocket 处理线程连接断开时remove如果这里用HashSetadd/remove正好和for遍历并发可能抛ConcurrentModificationException。而推送任务抛异常意味着这一轮广播中断——所有在线看板一起卡住。我用的是CopyOnWriteArraySetprivatefinalSetWebSocketSessionsessionsnewCopyOnWriteArraySet();选它的理由写在源码注释里连接数少、遍历多。设备看板的在线连接通常只有个位数到几十个但每 2 秒遍历一次——写频率极低、读频率极高正是写时复制最划算的场景。代价要说清楚CopyOnWriteArraySet每次add/remove都会复制整个底层数组。如果连接数涨到几千每次连接变更都要复制一遍成本会显现。我的场景是几十个连接完全可接受如果换成万级连接这个选择就该重新评估那时更合适的是ConcurrentHashMap.newKeySet()加上一套更严格的连接治理。2.3 面试怎么讲这个点如果面试官问你用什么集合存会话为什么标准答法有三层并发事实多线程并发 add/remove 与遍历不能用HashSet取舍依据连接少、遍历频繁 → 写时复制读几乎无锁知道边界写时复制的成本随连接数线性上升万级连接要换方案。第 3 层是关键。能说出这个方案在什么规模下会失效比背下CopyOnWriteArraySet的定义有用得多。三、坑二半开连接——“连着但不发”3.1 这是 WebSocket 最经典的坑设备在车间、网线被踢、笔记本合盖、NAT 超时——这些情况下连接没有正常关闭服务端这边的会话对象仍然是isOpen() true但底层 socket 已经死了。于是推送任务每 2 秒对着一个死连接sendMessage轻则数据丢了没人知道重则抛异常。而从浏览器视角看WebSocket 握手成功过、监听器也触发了readyState显示已连接但曲线就是不动。这个坑的恶劣之处在于它不自愈你不主动踢掉那个连接它就一直躺在集合里每一轮推送都浪费一次sendMessage调用。3.2 我的处理靠异常暴露靠剔除自愈代码里这段是防线}catch(IOExceptione){log.warn(推送失败剔除连接 {}: {},session.getId(),e.getMessage());sessions.remove(session);}sendMessage在对端已经消失时会抛IOException。我不去判断这个连接是不是半开的没有可靠办法而是等它自己抛异常然后把它踢掉让前端重连。配合isOpen()判断等于两道防线情况检测方式处理正常关闭afterConnectionClosed回调集合里remove明确关闭但未回调session.isOpen()为 false循环内remove半开连接sendMessage抛IOExceptioncatch里remove连接生命周期回调在类开头OverridepublicvoidafterConnectionEstablished(WebSocketSessionsession){sessions.add(session);log.info(WebSocket 接入: {}当前 {} 个连接,session.getId(),sessions.size());sendSnapshot(session);// 连上立刻给一帧前端不用等下一个推送周期}OverridepublicvoidafterConnectionClosed(WebSocketSessionsession,CloseStatusstatus){sessions.remove(session);log.info(WebSocket 断开: {} status{}当前 {} 个连接,session.getId(),status,sessions.size());}日志里带上sessions.size()是个小设计连接数变化能第一时间看出来。如果某天这个数字只涨不跌说明有连接没被正确清理——这是个很有用的早期信号。还有一条容易被忽略的afterConnectionEstablished里我调了sendSnapshot(session)连上就立刻推一帧不让前端白等一个 2 秒周期。这是体感提升最明显的一个小改动。3.3 降级推不通的时候还有轮询**WebSocket 不是不能失败它只是不能是唯一选择。**这个思路和上一篇文章一致但这次要点明它在这个坑里的作用/** * 设备概览 WebSocket 推送把前端 3 秒轮询一次升级为服务端 2 秒推一次。 * * 轮询没有删掉——前端连不上 WS 时自动退回轮询功能永远可用降级路线。 */坏连接被剔除后前端会走自己的重连逻辑如果重连一直失败比如网关不支持 WS、代理超时太短前端必须能退回去用GET /api/devices/overview轮询。两条路径返回的是同一份数据/** 与 GET /api/devices/overview 完全相同的响应结构前端一套渲染逻辑两处复用 */privateStringsnapshot(){try{ListOverviewService.DeviceOverviewdataoverviewService.build();returnobjectMapper.writeValueAsString(ApiResponse.ok(data));}catch(Exceptione){log.error(构建概览快照失败,e);return{\code\:500,\message\:\snapshot error\,\data\:[]};}}注意这个snapshot()的返回值结构ApiResponse.ok(data)的序列化结果和 REST 接口的响应体一模一样。前端不用为 WS 单独写一套解析切换路径时只换数据来源、不换渲染逻辑。这是让降级真正能落地的前提——如果两条路径的数据结构不同降级代码写了也不敢切。前端一套渲染逻辑吃的正是这份数据——所以不管是推送来的帧还是轮询来的响应看板长一个样四、坑三快照构建失败把异常抛给推送线程snapshot()里那个catch不是装饰它决定了一次数据库抖动会不会打挂整个推送}catch(Exceptione){log.error(构建概览快照失败,e);return{\code\:500,\message\:\snapshot error\,\data\:[]};}overviewService.build()会查设备表和聚合数据。如果某次查询超时抛了异常而这里没有catch异常会从broadcast()逃出去——Scheduled任务抛异常意味着本轮推送中断而且 Spring 对Scheduled抛异常的处理是打日志、后续调度直接停止。这才是最严重的一个坑一次偶发的数据库抖动可能让整个看板推送永久停掉而所有看板都表现为连着但没数据。所以这里的原则是推送通道本身必须永不抛异常。构建失败就返回一个结构合法的降级响应code:500、data空数组让前端按暂时没数据渲染而不是抛异常让整个推送链路停摆。同时别忘了log.error要留——降级是兜底不是掩盖。不记日志的话你只会看到看板没数据完全查不出是数据库抖了。五、三个坑的共同规律把三个坑放一起看它们指向同一件事维度单连接 demo多连接长时间运行会话集合1 个元素并发问题不显现几十个元素遍历/增删并发显现连接状态客户端一直在线不存在半开网络抖动产生半开连接数据库稳定几乎不失败偶发抖动正好撞上推送故障表现功能正常看板静默卡死且不自愈三个坑里没有一个是功能写错了全都是在真实运行条件下才暴露的时序与状态问题。这也是我写这一篇的初衷上一篇那篇的实测通过让我一度以为 WebSocket 这块已经做完了实际跑起来才发现推送路径是最脆弱的一环。如果让我给一条最重要的经验**涉及连接 定时任务 外部依赖三者的代码测试能验证的只是功能而真正决定它能不能活过一个生产日的是异常路径。**推不通就踢掉、算不出来就降级、别让异常逃出去——这三条守住了推送通道就不会静默死掉。六、一个我还没做但该做的加固诚实地说这个实现还有一处不完善没有心跳。现在的逻辑是发送失败就剔除隐含假设是推送失败一定能及时被发现。但sendMessage把数据写进 socket 发送缓冲区就返回了真正的网络中断要等对端 RST 或超时才暴露。这中间可能已经推了十几轮废数据。标准的做法是加一个PingMessage心跳WebSocket 协议原生支持 ping/pong服务端定期发 ping、约定时间内没收到 pong 就判定连接已死、主动剔除。我这一步还没做——记录在这里等我做了再补一篇文章。**写在这里是想说明上面那三个坑是已经踩到并修掉的这一条是知道还没修的。**两者不要混为一谈简历和面试里也一样——没做的不要写成做了但知道自己该做什么本身就是工程成熟度的信号。我是软件工程在读专升本正在从零搭一套物联网设备接入平台这个系列是项目的真实开发笔记——代码、压测数据、git 记录全部来自真实仓库没有编造的示例。收藏这篇能带走什么三张对照表——「现象 / 根因 / 改法」三坑总表、三种断连方式的检测与处理表、单连接 demo 与长期运行对照表。下次看板出现连着但没数据按表从上往下排一遍基本能定位到是哪个坑。加上第六节那条自查清单连接数日志、推送状态条就是一套现成的推送链路排查清单。下一篇预告第六节留下的没有心跳就是下一篇的主角——服务端PingMessage主动探活、约定时间内没收到 pong 就判死剔除把靠异常暴露半开连接升级成主动发现半开连接。会发在我的专栏「实时推送」里上一篇《Spring Boot 实时数据推送怎么做》和这篇都在那个专栏建议从专栏页按顺序看。最后想问你一个问题你的项目里有没有功能测试全过但上线跑一段时间就出问题的经历我这次是看板推送另外的接口尤其涉及线程池和超时的大概也藏着同类的坑——你踩过哪一类留言说说评论我都会回。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询