设备看板要"实时",新手第一反应是前端 setInterval 轮询。能用,但有两个天生的毛病。这篇用我设备接入平台里的真实代码,讲清楚轮询 → WebSocket 的完整升级路径,以及最容易被忽略的降级方案。
先看最终效果------这是我平台的真实运行截图,温湿度数据 2 秒一刷: 
设备在线状态、最新温湿度、60 点实时曲线、最新数据帧,全部来自 MQTT 真实上报链路,不是静态假数据。
轮询的两个毛病
我第一版看板就是前端每 3 秒 GET /api/devices/overview 一次。能用,但:
- 浪费:数据没变化也在发请求,100 个打开的页面 = 每分钟 2000 次无效请求
- 实时性上限:数据变化后最多要等 3 秒才能看到,这个延迟写死在轮询间隔里
所以升级成 WebSocket:服务端控制节奏,一条连接服务所有刷新需求。
核心实现:一个 Handler 干三件事
java
@Component
@RequiredArgsConstructor
public class OverviewWebSocketHandler extends TextWebSocketHandler {
private final OverviewService overviewService;
private final ObjectMapper objectMapper;
private final Set<WebSocketSession> sessions = new CopyOnWriteArraySet<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
sendSnapshot(session); // 连上立刻给一帧,前端不用等下一个推送周期
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
sessions.remove(session);
}
/** 每 2 秒向所有在线连接广播一次概览快照 */
@Scheduled(fixedDelayString = "${iot.ws.push-interval-ms:2000}", initialDelay = 5000)
public void broadcast() {
if (sessions.isEmpty()) {
return;
}
String payload = snapshot();
for (WebSocketSession session : sessions) {
try {
if (session.isOpen()) {
synchronized (session) {
session.sendMessage(new TextMessage(payload));
}
} else {
sessions.remove(session);
}
} catch (IOException e) {
log.warn("推送失败,剔除连接 {}: {}", session.getId(), e.getMessage());
sessions.remove(session);
}
}
}
}
三个实现取舍,每一个都是面试考点:
① 固定 2 秒推全量快照,而不是"有变化才推"
变化检测需要比对逻辑(脏标记/增量计算),而概览快照本身很小。先简单正确,将来设备多了再上增量------这个取舍比"上来就搞增量"聪明。
② sessions 用 CopyOnWriteArraySet
连接数少、每次广播都要遍历全部连接:读多写少的场景,写时复制比加锁更合适。如果连接数上万,这个选择就要重新评估。
③ 推送失败直接踢掉坏连接
sendMessage 抛 IOException 说明连接已坏(半开连接),不做复杂的健康检查,直接从集合剔除,等前端自己重连。简单,且不会泄漏死连接。
两个容易漏的细节
连上立刻推一帧 :afterConnectionEstablished 里马上 sendSnapshot。不然前端要干等最多 2 秒白屏。
sendMessage 要加锁 :synchronized (session)。Spring 的原生 WebSocketSession 不支持并发写 ,定时广播和连接建立时的首帧推送可能同时写同一个 session,不加锁会偶发 IllegalStateException: TEXT_PARTIAL_WRITING------这个报错搜不到几篇中文资料,我替你踩过了。
降级方案:WebSocket 挂了页面也不能死
网络环境复杂,公司代理、校园网都可能掐 WebSocket 长连接。所以轮询没有删掉,而是做成降级路线:
markdown
前端逻辑:
1. 优先建 WebSocket,收到推送就刷新
2. onclose / 超时未收到消息 → 自动切回 3 秒轮询
3. 每隔一段时间尝试重建 WebSocket,成功就切回推送
后端代码一行不用改------因为推送的数据结构和 GET /api/devices/overview 完全相同 (同一个 Service 构建、同一个 DTO),前端一套渲染逻辑两处复用。这是这次升级里我觉得最值的设计决策:数据契约不变,传输层随便换。
升级后的刷新效果(曲线自动延伸、右上角"已刷新"时间戳每 2 秒跳动):

面试怎么讲
被问"实时推送怎么做的",三层答法:
- 选型:看板类低频全量刷新,服务端定时推快照就够;如果是消息类要毫秒级,才考虑按事件推
- 并发:CopyOnWriteArraySet 适配读多写少;session 写要加锁,Spring 原生 session 不支持并发写
- 可靠性 :坏连接直接剔除等重连;必须保留轮询降级,WebSocket 不是可用性承诺而是体验增强
"必须保留降级"这一点说出来,面试官会知道你考虑过生产环境------WS 在复杂网络里就是会断,功能永远可用的兜底思维比技术本身值钱。
下一步
现在看板的数据链路是:模拟设备 → EMQX → 消费入库 → 定时聚合 → WebSocket 广播。再往下一步是把"设备注册/密钥管理"补成完整的管理端,然后上 Kafka 把消费和接入解耦------那是下一篇文章的事。
我是软件工程在读,正在从零搭一个物联网设备接入平台(Spring Boot + EMQX + MySQL),所有代码和踩坑记录都会发出来。欢迎关注,一起卷物联网后端。